diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e625b4ace..b20a293d1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -532,7 +532,7 @@ See [eval defaults and overrides](CONTRIBUTING.md#testing--evals). Tier 1 runs on every `bun run test`. Tiers 2+3 are gated behind `EVALS=1`. The idea: catch 95% of issues for free, use LLMs only for judgment calls and integration testing. -Anything that needs Aside itself — `test/skill-e2e-aside.test.ts`, the Aside cases in the qa and design-review E2E files, the live round-trip in `test/aside-render.test.ts` — runs only on a Mac with the Aside app open and self-skips elsewhere (`asideAvailable()` in `test/helpers/aside-available.ts`; `GSTACK_SKIP_ASIDE=1` forces the skip). The render gates are engine-agnostic: make-pdf's `*-gate.test.ts` and `test/skill-e2e-diagram.test.ts` run through whichever engine resolves (`browserAvailable()` in `make-pdf/test/e2e/browser-available.ts` = `asideAvailable() || resolveBrowseBin() !== null`) and skip only when neither exists, so Linux CI builds the browse binary with `bun run build:gates` and runs them live. The fallback engine's own tests (`browse/test/`, the `$B`-driven E2E cases) run on every platform as before: Linux CI proves the fallback path live and the Aside contract statically. +Anything that needs Aside itself — `test/skill-e2e-aside.test.ts`, the Aside cases in the qa and design-review E2E files, the live round-trip in `test/aside-render.test.ts` — runs only on a Mac with the Aside app open and self-skips elsewhere (`asideAvailable()` in `test/helpers/aside-available.ts`; `GSTACK_SKIP_ASIDE=1` forces the skip). The render gates are engine-agnostic: make-pdf's `*-gate.test.ts` and `test/skill-e2e-diagram.test.ts` run through whichever engine resolves (`browserAvailable()` in `test/helpers/browser-available.ts` = `asideAvailable() || resolveBrowseBin() !== null`) and skip only when neither exists, so Linux CI builds the browse binary with `bun run build:gates` and runs them live. The fallback engine's own tests (`browse/test/`, the `$B`-driven E2E cases) run on every platform as before: Linux CI proves the fallback path live and the Aside contract statically. ## What's intentionally not here diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f335970..37bc2eb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,45 @@ The first two rows are the ones to care about: a review that read nothing, or fo Contributed by @CermakM (#2843), @Haijie-Lee (#2192), @SomSamantray (#2965), @Vaughan-g-aus (#2329), @BharadwajMittapelli (#2729), @maxpetrusenkoagent (#1931, #1932, #1981), @Infiniteyieldai (#2884), @afshaker (#2778), @time-attack (#2232, #2229), @jzeisweiss (#2670), @liutiming (#2783), @agile-operators (#1437), @benjaminberes-bp (#2369), @lewispeel (#2885), @Yugz29 (#2899), @fcmerle (#2962), @cdecook23 (#2784), @mb1810 (#2913), @ntdatt812 (#2949), @pneumorea-ai (#2915), @gabrielrondon (#2811), @kevingrasso32 (#2876), @xwang4-svg (#2354), @DizzyWesterwald (#2936), @oli548 (#2771), @shofel (#1968), @ryanlaiwy (#1633), @yinanli1917-cloud (#2281), @Spooks444 (#2260), @punksterlabs (#2439), @himiaocc (#2492), @jbetala7 (#1419, #1777), @mamedov (#2891), @Lockyer228 (#2651), @topcoder1 (#2263), @jonahberg (#2707), @AlinValentin7 (#3015), @philhie (#2208), @Alchemist-DevAI (#2334), @merit-blake (#2343), @day-of-davon (#2952), @loulanyue (#2797, #2792, #2794, #2796), @Bit0ps (#2840), @szsunyuan (#2799), @frosimanuel (#2818), @JiayuuWang (#1096), @RyanAlberts (#1728), @exGeni (#2704), @Jino00 (#2812), @snig-17 (#2809) and @SholtoMc (#2037). +## [1.91.18.0] - 2026-10-03 + +**Transcript consent can cover only new sessions or only chosen repos, and Claude Code users can opt into a model-tuned skill overlay.** +**The safety rules and judges the Opus 5.5 cleanup left alone now have evals and calibration behind them.** + +This release closes the follow-ups filed by the v1.91.15.0 prompt cleanup. The cleanup had held six safety rules at their old wording and left the judge prompts untouched, because nothing could show a change was safe. Each now has an eval or a calibration corpus, and only the changes that passed them shipped. + +### What changes for you + +- **Narrower transcript consent.** The gbrain transcript gate offers "new sessions only", stored as `new@`, which ingests only sessions that started after you answered. `gstack-config set transcript_repos ` limits ingest to the repos you name. Turning ingest `off` keeps pages already staged, and only a narrower choice purges them. `gstack-config unset ` is new, and every config write now takes a lock. +- **Opt-in Claude overlay.** `./setup --claude-model ` renders the model's tuned skill overlay (opus-4-7, opus-4-8, sonnet-5, fable-5) and keeps it across upgrades and `gbrain-refresh`. `./setup --claude-model claude` returns to the generic overlay, and `./setup --status` shows which one is active. +- **Design docs follow your state root.** /plan-eng-review, /plan-ceo-review, /plan-devex-review and /autoplan find /office-hours design docs under `$GSTACK_STATE_ROOT` through the new `gstack-design-doc-find` helper, so custom state roots no longer miss them. +- **/design-shotgun** generates its variants with one `$D variants --briefs-file` call instead of one subagent per variant, then publishes every image with `gstack-design-claim`, so earlier rounds are never overwritten. +- **/qa and /qa-only** probe loops use readable names (`PROBE_DIR`, `DEADLINE_FILE`, `DEADLINE_TOOL`, `EVIDENCE_TOOL`) and a short "how one probe works" summary. The commands and machine-read markers are unchanged. +- **Codex outside voice** states its filesystem boundary at normal volume. A 10-trial eval showed Codex still keeps out of `~/.claude/`, `~/.agents/`, `.claude/skills/` and `agents/`. The pair-agent instruction block's divider is fixed. + +### Itemized changes + +#### Added +- Six safety-rule evals (`test/skill-e2e-safety-*.test.ts`) and a registry of held safety rules (`test/helpers/safety-rules.ts`) that fails when a listed rule's wording changes without its eval. +- Judge calibration: `scripts/judge-calibration.ts` and stored corpora in `test/fixtures/judge-calibration`. The arm, qa health-rubric, qa anti-refusal, cross-skill, voice and default workflow judges now send a JSON schema; each passed its flip-rate and false-pass checks against the old prompt. +- Archaeology lint (`test/archaeology-lint.test.ts`) flags issue numbers, plan IDs and incident stories in runtime skill prose. `bun run audit:manifest` prints the file slices to feed `/claude-api prompt-audit` at each frontier-model release (see CONTRIBUTING.md). +- The Opus 5.5 dedicated-tools overlay case runs in the periodic lane, and `plan-ceo-review-plan-mode` joins the fast PR profile. + +#### Changed +- The plan-floor assessment cap is 90 seconds (it was 30); the measured p95 is 27 seconds and the slowest run took 31. +- Parity caps lowered to measured sizes for land-and-deploy and ship. +- The codex consult section drops issue numbers from its runtime prose. + +#### Fixed +- /review's specialist header counted advisory findings in its total; the header's N is now defined as critical plus informational. +- Free-lane shards failed with "browser ownership deadline exceeded" when a browse daemon took more than 5 seconds to exit after SIGINT. Reaching the force-kill point now ends the graceful phase instead of recording a cleanup failure; a probe cut off at that point still fails the shard. +- `bun run audit:manifest` printed backslash paths on Windows. +- /review and /ship's design-review scope check sourced `gstack-diff-scope` without printing `SCOPE_FRONTEND`, so the agent could not see the value and reran the helper by hand. The block now prints it. +- Exploratory QA could publish its evidence before listing captures taken before an input change, which left the verdict inconclusive (materialize runs once). The probe loop now says to annotate every capture and mark older-snapshot ones `superseded`. + +#### For contributors +- Five safety rules and the qa workflow, review and cookie judges keep their wording; TODOS.md lists what each needs before it can change. + ## [1.91.17.0] - 2026-10-03 **Checkpoints say whether each next step was run, read or guessed, and `/context-restore` verifies the guesses first.** diff --git a/CLAUDE.md b/CLAUDE.md index 45c475642..1875cb940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ bun run eval:summary # aggregate stats across all eval runs bun run eval:flake-rank # rank tests by flake signal (retried passes first; --json, --dir, --since-days) bun run slop # full slop-scan report (all files) bun run slop:diff # slop findings in files changed on this branch only +bun run audit:manifest # file slices for /claude-api prompt-audit; rerun it at each frontier-model release (CONTRIBUTING.md) ``` `test:evals` requires `ANTHROPIC_API_KEY`. Codex E2E tests (`test/codex-e2e.test.ts`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af3f087d2..4ba76804b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -603,6 +603,31 @@ is a CLI flag) so every caller gets it on both paths. Exported test seams: `serveDir(root, nonce)`, `SAFE_TMP_DIR`, and `PAGE_NUMBER_FOOTER` (the one page-number footer make-pdf, `gstack-render`, and the browse `pdf` command share). +## Prompt audit at each frontier-model release + +When a new frontier Claude model ships, audit the text models read for +instructions the new model over-applies or no longer needs. The audit is +Anthropic's `/claude-api prompt-audit`, a Claude Code skill you run in your own +Claude Code session; gstack only prints what to feed it. + +```bash +bun run audit:manifest # slices of templates, resolvers, overlays, CLAUDE.md and wording-pinning tests (--json for a machine-readable list) +``` + +1. In Claude Code, run `/claude-api prompt-audit` with the new model as the + target, one slice at a time, giving it that slice's file list. `s01` is the + shared text every skill reads (CLAUDE.md, model overlays, preamble + resolvers); a skill's template and its sections share one slice. +2. Fix findings in templates and resolvers, never in generated SKILL.md files, + then run `bun run gen:skill-docs --host all`. +3. Treat safety rules as held: reword one only where an eval shows the model + obeys it both before and after the change. +4. Work through the "tests that pin skill wording" slices last. Where prose + changed, replace exact-sentence pins with structural or meaning checks + (the prompt-bytes rule in CLAUDE.md's testing section). +5. `test/archaeology-lint.test.ts` keeps issue numbers and incident stories out + of the generated text; its failure names the source file to fix. + ## Jargon list (V1 writing style) gstack's Writing Style section (injected into every tier-≥2 skill's preamble) diff --git a/TODOS.md b/TODOS.md index c29064492..a40962e9a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -226,79 +226,48 @@ Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10 Each item was deferred with a reason during the `/claude-api prompt-audit` cleanup. -- **Safety rules kept at their old volume until an eval covers them.** A rule's - wording changes only when an eval shows the model still obeys it before and - after. These keep their wording, and each needs the named eval first: - - `CODEX_BOUNDARY` (scripts/resolvers/outside-voice-steps.ts): a codex-host - /review run asserting Claude Code never reads `.claude/skills/` or `agents/`. - - /ship Step 16 "IRON LAW: NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION - EVIDENCE": a ship E2E that offers a stale-evidence completion and asserts the - step blocks. - - design-review stop conditions (risk %): an eval that reaches the stop - condition with no cap coaching, runs where `browserAvailable()` resolves, and - asserts termination plus a report of remaining findings. `design-review-fix` - self-skips without Aside and accepts `error_max_turns`. - - pair-agent "output the full instruction block": no eval exercises relaying - the block verbatim. - - codex consult-mode "embed content, don't reference path": no eval checks that - the plan content, not its path, reaches Codex. - - ios-qa "DEMO MODE OVERRIDES ALL OTHER RULES": no device-free eval covers demo - mode. - Priority P2. Effort M per eval. -- **Judge prompts still say "Respond with ONLY valid JSON"** — - `test/helpers/llm-judge.ts` (prompt-audit s11 M1-M7) and the five inline judges - in `test/skill-llm-eval.test.ts` (:223, :266, :315, :384, :823) already pass a - JSON schema or should. The change lands only with a calibration corpus: stored - judged outputs per changed judge (passing, failing, threshold-adjacent), an - old-vs-old flip rate, then new-vs-old not above it. Also move the qa - anti-refusal judge rubric (`skill-llm-eval.test.ts:291-343`) off "always … - regardless" wording in the same commit. Priority P2. Effort M. -- **Fast PR profile has no CEO E2E probe** — retiring `plan-ceo-review-benefits` - removed the only fast PR probe for /plan-ceo-review; CEO template changes now - run the CEO judge and `auq-format-gate`. Adding `plan-ceo-review-plan-mode` to - `PR_PROFILE_CASE_IDS` fails the "exactly one planned owning file" rule in - scripts/lib/paid-plan.ts because the file is planned as more than one shard. - Priority P3. Effort S. -- **Register an Opus 5.5 dedicated-tools overlay case** — the Opus 5.5 run was - measured as a one-off. Registering it as a periodic case moves the periodic - census walls, release floors and file counts pinned by - `test/paid-overlay-scheduling.test.ts`, `test/overlay-lifecycle.test.ts`, - `test/paid-retry-supervision.test.ts` and `test/eng-finding-retry-budget.test.ts`; - update those budgets in the same commit. Priority P3. Effort S. -- **Ratchet parity caps back down** — `test/helpers/carve-guards.ts` caps were - raised during the cleanup (qa 1.104, land-and-deploy 1.108, - design-consultation 1.092, ship 1.405). Re-measure on the merged tree and lower - each to its measured value. Priority P3. Effort S. -- **design-shotgun executor subagents** (prompt-audit s08 G1) — replace the N - subagents that only run a fixed script with one parallel bash batch. This - changes how variants are dispatched, not the prompt text. Priority P3. Effort S. -- **Per-record "new sessions only" transcript consent** — let users consent to - ingesting only sessions created after they answer. Needs timestamp filtering - inside both transcript parsers (Claude Code and Codex) and a mode-plus-cutoff - config write that lands as one transaction. Priority P3. Effort M. -- **Per-repo transcript scope** — `recent`/`all` cover every project on the - machine that repo policy allows. Add a way to restrict transcript ingest to - chosen repos. Priority P3. Effort M. -- **Design-doc discovery still reads `~/.gstack/projects`** — converting - `DESIGN_DOC_DISCOVERY` (and autoplan's Step 2 lookup) to `$GSTACK_STATE_ROOT` - made /plan-eng-review answer its no-target scope gate in prose instead of - AskUserQuestion: the plan-mode no-op eval failed 5 of 5 runs with the converted - paths and passed 6 of 6 without them. Custom state roots miss office-hours design docs - until this lands. Find why the path change shifts the gate (likely the gate's - "do not probe for session state" rule), then convert and keep the eval green. - Priority P2. Effort S. -- **Archaeology lint** — a free check that flags issue/PR numbers, plan IDs and - incident stories in runtime skill prose (templates and resolver strings), so - they stay in commit messages and CHANGELOG. Priority P3. Effort S. -- **Claude-side model detection** — Claude Code hosts always render the generic - `claude` overlay; `./setup --model` is Codex-only. When the next Claude model - needs its own overlay, detect the model on the Claude side first, or the - overlay never reaches users. Priority P2. Trigger: the next Claude model that - needs a tuned overlay. -- **Re-run the prompt audit at each frontier-model release** — run - `/claude-api prompt-audit` over templates, resolvers, overlays, CLAUDE.md and - the tests that pin them, and review the low-confidence flags it reports. - Priority P3. Trigger: each new frontier model. +- **Safety rules held: their evals do not discriminate** — the + safety-ship-stale-evidence, safety-pair-agent-block, safety-codex-consult-embed + and safety-ios-demo-ui-only evals passed 10/10 with the current wording, but + their rule-removed arms showed 0/5 violations (2026-10-03, claude-fable-5-1, + Claude Code 2.1.284), so they cannot show a rewording is safe. The wording stays + until a fixture makes the rule's absence observable. The rules are listed in + `test/helpers/safety-rules.ts`. Priority P3. Effort M per fixture. +- **design-review risk-stop rewording held (R8)** — safety-design-risk-stop + discriminates (current wording 10/10, rule removed 1/5 violated), but R8 needs a + clean run of the reworded rule on every render that carries it. The copilot, + cursor, factory, gbrain, hermes, kiro, openclaw, opencode and slate renders run + other models with no runner, and the `.agents` render has no Codex runner for + this eval. Narrow the rewording to the design-review render, or add per-host + runners. Priority P2. Effort M. +- **Judge prose and rubric cleanup (W2 deferral)** — the arm, qa health-rubric, + qa anti-refusal, cross-skill and voice judges, and the default workflow judge, + now send a JSON schema; their prompt-audit s11 M1-M7 prose cleanup is still + open, as is the qa anti-refusal rubric's "always … regardless" wording. Two + prose steps were triggered and not run: the qa workflow judge (its schema + comparison held: 4/24 new flips vs 2/24 old, one new false pass on a06, zero + format errors, so it stays on today's request) and the default workflow judge + (landed, but one provider refusal on corpus item f03 under the new prompt, + matched by one under the old prompt). Reuse `test/fixtures/judge-calibration` + and `scripts/judge-calibration.ts`. Priority P3. Effort M. +- **Uncalibrated workflow-judge configurations** — build corpora and calibrate + schema transport for the frontier configuration (review/SKILL.md workflow) and + the cookie configuration (setup-browser-cookies/SKILL.md workflow); both stay on + today's request. After any transport change, re-run the review and cookie + workflow judges. Priority P3. Effort M. +- **Claude model auto-detection for the skill overlay** — when no + `claude_overlay_model` is set, detect the Claude Code model (`ANTHROPIC_MODEL`, + user and managed settings `model`) in `./setup` and render its pinned overlay. + `./setup --claude-model` is opt-in, so users who never pass it keep the generic + overlay. A `/model` switch would leave a stale render, and the pinned overlays + are not yet measured against `claude` on their own models. + `bin/gstack-render-claude.sh`, its activation record and the change-only banner + are in place; detection would record `source=detected`. Depends on: task-result + evidence that a pinned overlay beats `claude` on its model. Priority P3. +- **Per-session overlay selection (E6)** — a runtime hook that follows `/model` + switches and serves the matching overlay per session. `./setup --claude-model` + is install-wide, so a mid-session switch keeps the installed overlay. Builds on + the `--claude-model` render and activation record. Priority P3. ### P2/P3: impeccable interop deferrals (filed 2026-09-08, from the CEO + eng reviews of docs/designs/IMPECCABLE_INTEROP.md) diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index b855b9b9b..4bba21fe2 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -441,19 +441,7 @@ branch name wherever the instructions say "the base branch" or ``. setopt +o nomatch 2>/dev/null || true # zsh compat SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) -# Repo-local docs win when at least as fresh (#703): office-hours dual-writes -# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates -# see. A stale old repo doc never shadows a newer private session. -_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") -_REPODOC="" -# Only docs/designs/ holds feature design docs; a root DESIGN.md is the design system (#2839). -[ -n "$_REPOTOP" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1) -DESIGN="$_LOCALDOC" -if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then - DESIGN="$_REPODOC" -fi +DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH") [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` If a design doc exists, read it and use its problem statement, constraints, and @@ -508,19 +496,7 @@ After /office-hours completes, re-run the design doc check: setopt +o nomatch 2>/dev/null || true # zsh compat SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) -# Repo-local docs win when at least as fresh (#703): office-hours dual-writes -# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates -# see. A stale old repo doc never shadows a newer private session. -_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") -_REPODOC="" -# Only docs/designs/ holds feature design docs; a root DESIGN.md is the design system (#2839). -[ -n "$_REPOTOP" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1) -DESIGN="$_LOCALDOC" -if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then - DESIGN="$_REPODOC" -fi +DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH") [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` @@ -718,7 +694,7 @@ Re-run: copy RESTORE_PATH's bytes to SOURCE_PLAN, then /autoplan. ### Step 2: Read context - Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat -- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` +- Discover design docs: `~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH"` (prints the doc path, or nothing) - Detect UI scope: grep the plan for view/rendering terms (component, screen, form, button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude false positives ("page" alone, "UI" in acronyms). @@ -814,7 +790,7 @@ Branch on the echoed `CODEX_MODE`: - **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider. - **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate. - **`broken_install`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: `npm install -g @openai/codex`." Relay the probe's HINT lines. Keep the required Claude adversarial pass; do not dispatch a duplicate. -- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (#2477: HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=` or config.toml `model`); never substitute a model. Keep the required Claude adversarial pass; do not dispatch a duplicate. The ~10s round trip is cached for 1h. +- **`model_unusable`** — the selected model (see `CODEX_MODEL:`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (`GSTACK_CODEX_MODEL=` or config.toml `model`); never substitute a model. Keep the required Claude adversarial pass; do not dispatch a duplicate. The ~10s round trip is cached for 1h. - **`sandbox_unavailable`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. Keep the required Claude adversarial pass; do not dispatch a duplicate. - **`ready`** or **`unverified`** — run the Codex pass below. `unverified` means the model check timed out; say so, and let the pass's own verdict decide. diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 640530aca..d3e9015c0 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -224,7 +224,7 @@ Re-run: copy RESTORE_PATH's bytes to SOURCE_PLAN, then /autoplan. ### Step 2: Read context - Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat -- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` +- Discover design docs: `~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH"` (prints the doc path, or nothing) - Detect UI scope: grep the plan for view/rendering terms (component, screen, form, button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude false positives ("page" alone, "UI" in acronyms). diff --git a/autoplan/sections/tasks-aggregator.md b/autoplan/sections/tasks-aggregator.md index 8153921ae..c9d585b85 100644 --- a/autoplan/sections/tasks-aggregator.md +++ b/autoplan/sections/tasks-aggregator.md @@ -32,7 +32,7 @@ if command -v jq >/dev/null 2>&1; then # .commit must be bound BEFORE piping to the split commit array: a # pipe rebinds jq's context, so a bare .commit after it indexes the # ARRAY with a string, every line errors into 2>/dev/null, and the - # aggregate is empty forever — the #2018 zero-tasks bug. + # aggregate is empty forever. jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \ '.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \ "$f" 2>/dev/null >> "$ALL_JSONL" || true diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 332d172aa..8c32a5eb0 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -393,15 +393,38 @@ for p in final: PYEOF } -# Transcript consent: transcript_ingest_mode is recent or all (trimmed, any -# case). An absent key, off, a legacy value or anything else is not consent. -# Same rule as normalizeTranscriptConsent in gstack-memory-ingest.ts. +# Transcript consent comes from the one shared policy reader +# (lib/transcript-consent.ts): every merged state root, the new@ cutoff and the +# transcript_repos allowlist. Without bun nothing transcript-related can be +# checked, so transcript pages are held (fail-closed). +CONSENT_TS="$SCRIPT_DIR/../lib/transcript-consent.ts" +transcript_policy() { + command -v bun >/dev/null 2>&1 || return 2 + GSTACK_STATE_ROOT="$GSTACK_HOME" bun run "$CONSENT_TS" "$@" +} + +# 0 when staged transcript pages may sync at all. transcripts_consented() { - "$CONFIG_BIN" has transcript_ingest_mode 2>/dev/null || return 1 - local v - v=$("$CONFIG_BIN" get transcript_ingest_mode 2>/dev/null || true) - v=$(printf '%s' "$v" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') - [ "$v" = "recent" ] || [ "$v" = "all" ] + transcript_policy --check 2>/dev/null +} + +# Before every push: scan the unpublished range for transcript pages outside +# the current consent; when some exist, the helper rewrites gstack-brain-sync's +# own unpublished commits without them, or refuses (exit 3) when it cannot. +# Returns 0 when the push may go ahead. A range that touches no transcript +# page needs no scan (and no bun). +guard_unpublished_range() { + local branch range="HEAD" rc=0 + branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [ -n "$branch" ] && [ "$branch" != "HEAD" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$branch" >/dev/null 2>&1; then + range="origin/$branch..HEAD" + fi + [ -n "$(git -C "$GSTACK_HOME" log --format= --name-only "$range" -- transcripts 2>/dev/null | head -1)" ] || return 0 + transcript_policy --guard-unpublished || rc=$? + if [ "$rc" -eq 2 ]; then + echo "BRAIN_SYNC: push blocked: unpushed commits hold transcript pages and bun is not on PATH to check them against your transcript consent" >&2 + fi + [ "$rc" -eq 0 ] } # Finalize the drain: delete exactly the spool record files this drain @@ -637,7 +660,7 @@ subcmd_once() { det_now=$(date +%s) det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0) case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac - if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ]; then + if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ] && guard_unpublished_range; then echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true local det_host det_host=$(remote_host) @@ -676,6 +699,9 @@ subcmd_once() { # snapshot manifest but no classification. Finalizing on that state is what # used to wipe the queue — so on a nonzero exit, warn loudly, do NOT call # finalize_queue, and leave everything queued for the next drain. + # Tightened consent (new@ cutoff, narrower transcript_repos): remove the + # unpublished staged pages it now excludes before anything is staged. + [ -d "$GSTACK_HOME/transcripts" ] && { transcript_policy --purge || true; } local transcripts_ok=0 transcripts_consented && transcripts_ok=1 if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" > "$paths_file"; then @@ -743,6 +769,8 @@ subcmd_once() { exit 0 } + # Nothing outside the transcript consent is pushed; a refused rewrite keeps the commit local. + guard_unpublished_range || { write_status "blocked" "push blocked: unpushed commits hold transcript pages outside your transcript consent; commit retained locally"; finalize_queue "$snapshot_file" "$class_file" "$paths_file"; exit 0; } # Push. On reject, fetch + merge (merge driver handles JSONL) + retry once. local push_err push_err=$(git -C "$GSTACK_HOME" push origin HEAD 2>&1 >/dev/null) || { @@ -766,7 +794,7 @@ subcmd_once() { bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then local branch branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main) - if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then + if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \ bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then finalize_queue "$snapshot_file" "$class_file" "$paths_file" diff --git a/bin/gstack-config b/bin/gstack-config index f2dd02c11..37ea5b071 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -7,6 +7,7 @@ # config file (get returns DEFAULTS for absent keys, # so callers that need provenance use this instead) # gstack-config set — write a config value +# gstack-config unset — remove a key from the config file # gstack-config list — show all config (values + defaults) # gstack-config defaults — show just the defaults table # @@ -92,17 +93,24 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Flip back to false to be re-prompted. # # ─── Transcript ingest (gbrain memory) ─────────────────────────────── -# transcript_ingest_mode: off # recent | all | off — consent for /sync-gbrain -# # to ingest Claude Code and Codex session -# # transcripts (every project repo policy allows) -# # into your brain (local or Supabase). +# transcript_ingest_mode: off # recent | all | off | new@ — consent for +# # /sync-gbrain to ingest Claude Code and Codex +# # session transcripts (every project repo policy +# # allows) into your brain (local or Supabase). # # recent — last 90 days # # all — all history +# # new@2026-10-03T17:00:00Z — only sessions that +# # start after that UTC time # # off — never (other memory still syncs) +# # A +repos suffix is kept automatically while +# # transcript_repos is set. # # Absent, legacy (A-E, incremental) or unknown # # values are not consent: transcripts are skipped # # and /sync-gbrain asks once. An invalid value is # # REJECTED and the stored value kept. +# transcript_repos: # comma-separated git remotes; only transcripts +# # from these repos are ingested. Clear it with +# # `gstack-config unset transcript_repos`. # # Docs: setup-gbrain/memory.md#transcripts # # ─── Timeline Stop hook ────────────────────────────────────────────── @@ -135,6 +143,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # The vendor capture consent (`memorable enable`) # # is separate; gstack never sets it. # +# ─── Claude skill overlay ──────────────────────────────────────────── +# claude_overlay_model: claude # Claude model ID whose skill overlay ./setup +# # renders (e.g. claude-opus-4-8). Absent = the +# # generic claude overlay. Set it with +# # ./setup --claude-model , which validates the +# # ID and re-renders; ./setup --claude-model claude +# # returns to the generic overlay. +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # Workflow outside review (Codex or Claude Code by harness). # # enabled: /review, /ship, /document-release, plan reviews, @@ -208,6 +224,7 @@ lookup_default() { memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable` founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false browse_extension_id) echo "" ;; # empty → browse trusts gstack's own extension ID (D1) + claude_overlay_model) echo "claude" ;; # set only by ./setup --claude-model; presence via `has` # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, @@ -224,7 +241,8 @@ lookup_default() { # the callers already assume in their own `|| echo ""` fallback. question_tuning) echo "false" ;; team_mode) echo "false" ;; - transcript_ingest_mode) echo "off" ;; # recent | all | off — transcript consent; absent = not consented (use `has`) + transcript_ingest_mode) echo "off" ;; # recent | all | off | new@ [+repos] — transcript consent; absent = not consented (use `has`) + transcript_repos) echo "" ;; # empty = every repo; comma-separated canonical remotes narrow transcript ingest # repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty # answer as a user override and skips its own classification entirely, so # a synthesized "unknown" default turns the classifier into dead code. @@ -352,14 +370,16 @@ resolve_user_slug() { _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" + # Persist via the locked read-modify-write (no recursion into `set`). + config_lock + config_edit_begin + if [ -z "$(_cfg_get "user_slug_at_${_hash}")" ]; then + _cfg_put "user_slug_at_${_hash}" "$_slug" + config_edit_commit + else + rm -f "$_cfg_tmp" fi + config_unlock printf '%s' "$_slug" } @@ -370,6 +390,142 @@ read_config_value() { gstack_read_config_key "$1" } +# ── Config writes: one lock, one read-modify-write rename ────────────────── +# Every mutation of config.yaml (set, unset, the transcript_repos pair, the +# user-slug writer) holds an atomic mkdir lock and replaces the file with one +# rename, so concurrent writers never lose each other's keys and an +# interrupted write leaves the old file. The pid file holds " "; +# a lock whose holder is dead or older than 10 s is taken over. No flock(1): +# stock macOS does not ship it. +CONFIG_LOCK="$STATE_DIR/.config-yaml.lock" +_config_locked="" + +config_lock() { + mkdir -p "$STATE_DIR" + local waited=0 held pid ts stale + while ! mkdir "$CONFIG_LOCK" 2>/dev/null; do + if [ ! -w "$STATE_DIR" ]; then + echo "Error: cannot write to $STATE_DIR (its config lock could not be created). Existing value left unchanged." >&2 + exit 1 + fi + waited=$(( waited + 1 )) + if [ "$waited" -gt 400 ]; then + echo "Error: $CONFIG_FILE is locked by another gstack-config ($(cat "$CONFIG_LOCK/pid" 2>/dev/null || echo 'unknown holder')). Existing value left unchanged. fix: retry, or remove $CONFIG_LOCK if no gstack-config is running." >&2 + exit 1 + fi + held=$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true) + pid=""; ts=""; stale="" + case "$held" in *" "*) pid="${held%% *}"; ts="${held#* }"; ts="${ts%%[!0-9]*}" ;; esac + if [ -n "$pid" ]; then + if ! kill -0 "$pid" 2>/dev/null; then stale=1 + elif [ -n "$ts" ] && [ $(( $(date +%s) - ts )) -gt 10 ]; then stale=1; fi + elif [ "$waited" -gt 200 ]; then + stale=1 + fi + # Take over only the lock we judged: a holder that changed meanwhile is live. + if [ -n "$stale" ] && [ "$(cat "$CONFIG_LOCK/pid" 2>/dev/null || true)" = "$held" ]; then + rm -rf "$CONFIG_LOCK" 2>/dev/null || true + continue + fi + sleep 0.05 + done + printf '%s %s\n' "$$" "$(date +%s)" > "$CONFIG_LOCK/pid" + _config_locked=1 + trap 'config_unlock' EXIT +} + +config_unlock() { + [ -n "$_config_locked" ] || return 0 + rm -rf "$CONFIG_LOCK" 2>/dev/null || true + _config_locked="" +} + +# config_edit_begin — copy config.yaml (or the annotated header for a new +# file) to a temp file beside it; edits go there until config_edit_commit. +config_edit_begin() { + _cfg_tmp="$(mktemp "$STATE_DIR/.config.yaml.XXXXXX")" + if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" > "$_cfg_tmp"; else printf '%s' "$CONFIG_HEADER" > "$_cfg_tmp"; fi +} + +config_edit_commit() { + mv "$_cfg_tmp" "$CONFIG_FILE" +} + +# _cfg_get KEY — last value of KEY in the temp file ("" when absent). +_cfg_get() { + awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) == k ":" { v = substr($0, n + 1) } END { sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v); print v }' "$_cfg_tmp" +} + +# _cfg_put KEY VALUE — replace every KEY line in the temp file, or append one. +# The value travels through ENVIRON so awk applies no escape processing. +_cfg_put() { + _CFG_V="$2" awk -v k="$1" 'BEGIN { n = length(k) + 1; v = ENVIRON["_CFG_V"] } substr($0, 1, n) == k ":" { print k ": " v; done = 1; next } { print } END { if (!done) print k ": " v }' "$_cfg_tmp" > "$_cfg_tmp.n" + mv "$_cfg_tmp.n" "$_cfg_tmp" +} + +_cfg_del() { + awk -v k="$1" 'BEGIN { n = length(k) + 1 } substr($0, 1, n) != k ":"' "$_cfg_tmp" > "$_cfg_tmp.n" + mv "$_cfg_tmp.n" "$_cfg_tmp" +} + +# ── Transcript consent grammar ───────────────────────────────────────────── +# transcript_ingest_mode = [@][+repos]; base is +# recent, all, off or new. `@` is required for new and invalid otherwise; off +# never carries +repos. `set` takes the canonical lowercase form (readers in +# lib/transcript-consent.ts are case-insensitive). +valid_utc_second() { + [[ "$1" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$ ]] || return 1 + local y=$((10#${BASH_REMATCH[1]})) m=$((10#${BASH_REMATCH[2]})) d=$((10#${BASH_REMATCH[3]})) + local hh=$((10#${BASH_REMATCH[4]})) mm=$((10#${BASH_REMATCH[5]})) ss=$((10#${BASH_REMATCH[6]})) dim=31 + [ "$m" -ge 1 ] && [ "$m" -le 12 ] && [ "$hh" -le 23 ] && [ "$mm" -le 59 ] && [ "$ss" -le 59 ] || return 1 + case "$m" in + 4|6|9|11) dim=30 ;; + 2) dim=28; if [ $((y % 4)) -eq 0 ] && { [ $((y % 100)) -ne 0 ] || [ $((y % 400)) -eq 0 ]; }; then dim=29; fi ;; + esac + [ "$d" -ge 1 ] && [ "$d" -le "$dim" ] +} + +# valid_transcript_mode VALUE — canonical grammar check (exit 0 = valid). +valid_transcript_mode() { + local v="$1" base + base="${v%+repos}" + case "$base" in + recent|all) ;; + off) [ "$base" = "$v" ] || return 1 ;; + new@*) valid_utc_second "${base#new@}" || return 1 ;; + *) return 1 ;; + esac +} + +# canonical_remote URL — same rules as canonicalizeRemote in +# lib/gstack-memory-helpers.ts (test/transcript-consent.test.ts keeps them equal). +canonical_remote() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}" + s="${s#[\"\']}"; s="${s%[\"\']}" + if [[ "$s" =~ ^[^@[:space:]]+@([^:]+):(.+)$ ]]; then + s="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + else + s=$(printf '%s' "$s" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#^[^@/]+@##') + fi + printf '%s' "$s" | sed -E 's#/+$##; s#\.[gG][iI][tT]$##; s#/+$##; s#/{2,}#/#g' | tr '[:upper:]' '[:lower:]' +} + +# transcript_marker_sync — keep the +repos marker on transcript_ingest_mode in +# step with transcript_repos inside the temp file: present while an allowlist +# exists and the base consents, absent otherwise. Unrecognized values are +# left untouched (they are not consent either way). +transcript_marker_sync() { + local mode list base + mode=$(_cfg_get transcript_ingest_mode) + [ -n "$mode" ] || return 0 + valid_transcript_mode "$mode" || return 0 + list=$(_cfg_get transcript_repos) + base="${mode%+repos}" + if [ -n "$list" ] && [ "$base" != "off" ]; then _cfg_put transcript_ingest_mode "$base+repos" + else _cfg_put transcript_ingest_mode "$base"; fi +} + _STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" _SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config" @@ -513,12 +669,31 @@ case "${1:-}" in echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2 exit 1 fi - # transcript_ingest_mode is a CONSENT key: recent/all let /sync-gbrain + # transcript_ingest_mode is a CONSENT key: recent/all/new@ let /sync-gbrain # ingest coding-agent transcripts. Reject a typo; never coerce or store it. - if [ "$KEY" = "transcript_ingest_mode" ] && [ "$VALUE" != "recent" ] && [ "$VALUE" != "all" ] && [ "$VALUE" != "off" ]; then + if [ "$KEY" = "transcript_ingest_mode" ] && ! valid_transcript_mode "$VALUE"; then echo "Error: transcript_ingest_mode '$VALUE' not recognized. Valid values: recent, all, off. Existing value left unchanged." >&2 + echo "Also valid: new@ (only sessions that start after that UTC time, e.g. new@$(date -u +%Y-%m-%dT%H:%M:%SZ)). The +repos suffix follows transcript_repos; off never carries it. Docs: setup-gbrain/memory.md#transcripts" >&2 exit 1 fi + # transcript_repos narrows transcript consent to these repos. Each entry is + # stored as its canonical remote (host/owner/repo); an empty list would + # deny every repo, so it is rejected: `unset` clears the allowlist. + if [ "$KEY" = "transcript_repos" ]; then + _clean="" + _ifs="$IFS"; IFS=',' + for _x in $VALUE; do + _x="$(canonical_remote "$_x")" + [ -n "$_x" ] || continue + case ",$_clean," in *",$_x,"*) ;; *) _clean="${_clean:+$_clean,}$_x" ;; esac + done + IFS="$_ifs" + if [ -z "$_clean" ]; then + echo "Error: transcript_repos '$VALUE' names no repo. Valid values: comma-separated git remotes (e.g. github.com/owner/repo). Existing value left unchanged. To allow every repo: gstack-config unset transcript_repos" >&2 + exit 1 + fi + VALUE="$_clean" + fi # memorable_recall is a CONSENT key: `on` lets a Claude Code hook hand every # prompt to a third-party binary. Reject like codex_reviews -- a typo must # never flip consent in either direction, so nothing is coerced or stored. @@ -592,21 +767,23 @@ case "${1:-}" in done done fi - mkdir -p "$STATE_DIR" - # Write annotated header on first creation - if [ ! -f "$CONFIG_FILE" ]; then - printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" + # Locked read-modify-write; the annotated header is written on first creation. + config_lock + config_edit_begin + if [ "$KEY" = "transcript_ingest_mode" ] && [ "${VALUE%+repos}" != "$VALUE" ] && [ -z "$(_cfg_get transcript_repos)" ]; then + rm -f "$_cfg_tmp" + echo "Error: transcript_ingest_mode '$VALUE' needs a repo allowlist, but transcript_repos is not set. Existing value left unchanged. fix: gstack-config set transcript_repos , then set transcript_ingest_mode ${VALUE%+repos}" >&2 + exit 1 fi - # Escape sed replacement metacharacters (newlines were rejected above). + _cfg_put "$KEY" "$VALUE" + # The allowlist and its +repos marker change together, in this one rename. + if [ "$KEY" = "transcript_ingest_mode" ] || [ "$KEY" = "transcript_repos" ]; then + transcript_marker_sync + if [ "$KEY" = "transcript_ingest_mode" ]; then VALUE="$(_cfg_get transcript_ingest_mode)"; fi + fi + config_edit_commit + config_unlock SAFE_VALUE="$VALUE" - ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')" - if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then - # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) - _tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")" - sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" - else - echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE" - fi # Report only when another root still overrides the value just written. gstack_config_select "$KEY" if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then @@ -627,6 +804,21 @@ case "${1:-}" in fi fi ;; + unset) + KEY="${2:?Usage: gstack-config unset }" + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + exit 1 + fi + [ -f "$CONFIG_FILE" ] || exit 0 + config_lock + config_edit_begin + _cfg_del "$KEY" + # Clearing the allowlist drops the +repos marker in the same write. + if [ "$KEY" = "transcript_repos" ]; then transcript_marker_sync; fi + config_edit_commit + config_unlock + ;; list) if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" @@ -682,6 +874,7 @@ case "${1:-}" in # Run this after installing or uninstalling gbrain so your locally # generated SKILL.md files match your installation state. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + . "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; } DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect" DETECTION_FILE="$STATE_DIR/gbrain-detection.json" mkdir -p "$STATE_DIR" @@ -689,10 +882,10 @@ case "${1:-}" in 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" + 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" + 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. @@ -747,10 +940,14 @@ case "${1:-}" in # SKILL.md link dangling — the whole skill set vanished from # Claude Code until a successful re-render. A failed render now # leaves the previous render fully intact. + gstack_claude_overlay "$INSTALL_DIR" "$0" RENDER_TMP="$RENDER_DIR.tmp.$$" rm -rf "$RENDER_TMP" - if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" >/dev/null 2>&1 ); then + if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" >/dev/null 2>&1 ); then + gstack_render_lock "$RENDER_DIR" _swap_in_render "$RENDER_DIR" "$RENDER_TMP" + gstack_render_unlock "$RENDER_DIR" + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok rendered # Repoint installed skills at the render — gstack-relink prefers # the render dir when present. "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true @@ -760,17 +957,46 @@ case "${1:-}" in rm -rf "$RENDER_TMP" echo "Warning: render failed — previous render (if any) left in place, links stay valid." echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error." + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok failed + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true fi fi ;; *) 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." + # Drop a brain-aware render, or re-render a pinned overlay without + # brain blocks, so removing gbrain keeps the chosen overlay. + INSTALL_DIR="$HOME/.claude/skills/gstack" + RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}" + if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model; } \ + && [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then + if ! command -v bun >/dev/null 2>&1; then + echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'." + else + gstack_claude_overlay "$INSTALL_DIR" "$0" + gstack_claude_render_action "$RENDER_DIR" absent + if [ "$_GSTACK_RENDER_ACTION" = plain ]; then + if gstack_claude_render_plain "$INSTALL_DIR" "$RENDER_DIR"; then + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent rendered + else + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent failed + fi + else + if [ -d "$RENDER_DIR" ]; then + rm -rf "$RENDER_DIR" + echo "Removed the brain-aware render at $RENDER_DIR; Claude skills serve the committed files." + fi + gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent removed + fi + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true + fi + fi ;; esac ;; *) - echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" + echo "Usage: gstack-config {get|has|set|unset|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" exit 1 ;; esac diff --git a/bin/gstack-design-doc-find b/bin/gstack-design-doc-find new file mode 100755 index 000000000..495f8b5cc --- /dev/null +++ b/bin/gstack-design-doc-find @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# gstack-design-doc-find — print the design doc a plan review should read. +# +# Usage: gstack-design-doc-find +# +# Prints one absolute path, or nothing when no design doc exists. Exit 0 either +# way; exit 2 on a usage error. +# +# Precedence: +# 1. newest $GSTACK_STATE_ROOT/projects//*--design-*.md +# 2. else newest $GSTACK_STATE_ROOT/projects//*-design-*.md +# 3. the newest repo doc under $TOP/docs/designs/ wins when no state-root doc +# exists or it is newer than the state-root doc. A root DESIGN.md is the +# design system /design-consultation writes, never a design doc (#2839). +# office-hours writes both copies; the committed copy is what teammates +# see, but a stale repo doc never shadows a newer private session. +set -u + +if [ "$#" -ne 2 ] || [ -z "$1" ]; then + echo "usage: gstack-design-doc-find " >&2 + exit 2 +fi +SLUG=$1 +BRANCH=$2 + +eval "$("$(dirname "$0")/gstack-paths")"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" + +newest() { + ls -t "$@" 2>/dev/null | head -1 +} + +PROJECT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG" +LOCALDOC=$(newest "$PROJECT_DIR"/*-"$BRANCH"-design-*.md) +[ -z "$LOCALDOC" ] && LOCALDOC=$(newest "$PROJECT_DIR"/*-design-*.md) + +REPODOC="" +TOP=$(git rev-parse --show-toplevel 2>/dev/null || true) +[ -n "$TOP" ] && REPODOC=$(newest "$TOP"/docs/designs/*.md) + +DESIGN="$LOCALDOC" +if [ -n "$REPODOC" ] && { [ -z "$LOCALDOC" ] || [ "$REPODOC" -nt "$LOCALDOC" ]; }; then + DESIGN="$REPODOC" +fi +[ -n "$DESIGN" ] && printf '%s\n' "$DESIGN" +exit 0 diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 10340af21..8ea45bead 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -56,7 +56,14 @@ import { buildGbrainEnv, spawnGbrain, spawnGbrainAsync, execGbrainJson, NEEDS_SH import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; import { checkOwnedStagingDir } from "../lib/staging-guard"; import { resolveStateRoot } from "../lib/state-root"; -import { normalizeTranscriptConsent, type TranscriptConsent } from "./gstack-memory-ingest"; +import type { TranscriptConsent } from "./gstack-memory-ingest"; +import { + describeTranscriptPolicy, + isScoped, + readTranscriptConsent, + reposHash, + type TranscriptPolicy, +} from "../lib/transcript-consent"; // ── Types ────────────────────────────────────────────────────────────────── @@ -120,11 +127,15 @@ interface StageResult { } interface TranscriptConsentRecord { - /** Normalized transcript_ingest_mode: recent | all | off | not-set | legacy | unrecognized. */ + /** Normalized transcript_ingest_mode: recent | all | new | off | not-set | legacy | unrecognized | repos-unreadable. */ mode: string; - window: "recent" | "all" | null; + window: "recent" | "all" | "new" | null; /** Why transcripts were not ingested; absent when they were. */ skip_reason?: string; + /** The new@ cutoff in force; a changed cutoff restages. */ + cutoff?: string; + /** Hash of the transcript_repos allowlist in force; a scope change restages. */ + repos_hash?: string; } // ── Constants ────────────────────────────────────────────────────────────── @@ -351,16 +362,28 @@ export function transcriptConsentNotice(consent: TranscriptConsent, quiet: boole if (consent.affirmative) return null; const value = consent.value ?? "not set"; if (override) { - return `gbrain-sync: transcripts ingested because ${override} names transcript (transcript_ingest_mode=${value}). ${TRANSCRIPT_CHOICE_HINT}`; + const scope = isScoped(consent) ? " The new@ cutoff and transcript_repos allowlist still apply." : ""; + return `gbrain-sync: transcripts ingested because ${override} names transcript (transcript_ingest_mode=${value}).${scope} ${TRANSCRIPT_CHOICE_HINT}`; } if (consent.reason === "off") return quiet ? null : "gbrain-sync: transcripts off (your choice)"; + if (consent.reason === "repos-unreadable") { + return `gbrain-sync: transcripts skipped (transcript_ingest_mode=${value}): its +repos marker needs a transcript_repos allowlist, which is missing or empty. Other memory still syncs. ${TRANSCRIPT_CHOICE_HINT}`; + } return `gbrain-sync: transcripts skipped (transcript_ingest_mode=${value}). Other memory still syncs. ${TRANSCRIPT_CHOICE_HINT}`; } -/** The consent record a memory stage stores; resume requires mode and window to match. */ +/** The consent in words for a consenting run (window or cutoff, repos, config roots). */ +export function transcriptConsentSummary(consent: TranscriptPolicy, quiet: boolean): string | null { + if (!consent.affirmative || quiet) return null; + return `gbrain-sync: transcripts: ${describeTranscriptPolicy(consent)}`; +} + +/** The consent record a memory stage stores; resume requires every field to match. */ export function transcriptConsentRecord(consent: TranscriptConsent, override: boolean): TranscriptConsentRecord { const record: TranscriptConsentRecord = { mode: consent.reason, window: consent.window }; if (!consent.affirmative && !override) record.skip_reason = consent.reason; + if (consent.cutoff !== undefined) record.cutoff = consent.cutoff; + if (consent.repos !== undefined) record.repos_hash = reposHash(consent.repos); return record; } @@ -500,10 +523,12 @@ Options: except ${FEDERATED_CURATED_TYPES.join(",")} when a federated gstack source already indexes them, and except transcript unless transcript_ingest_mode is recent - (last 90 days) or all (all history; --full passes - --all-history). Without that consent each run prints a - notice, even with --quiet. A list naming transcript - overrides the mode for that run. See + (last 90 days), all (all history; --full passes + --all-history) or new@ (sessions started after + it). Without that consent each run prints a notice, + even with --quiet. A list naming transcript overrides + the mode for that run, but a new@ cutoff and the + transcript_repos allowlist still apply. See setup-gbrain/memory.md#transcripts. --help This text. @@ -1481,12 +1506,12 @@ export function ensureGbrainSourceGitignored(root: string): void { } } -function printTranscriptNotice(args: CliArgs, consent: TranscriptConsent, selection: ConsentedSelection, why: string): void { - const notice = transcriptConsentNotice(consent, args.quiet, selection.override ? why : null); +function printTranscriptNotice(args: CliArgs, consent: TranscriptPolicy, selection: ConsentedSelection, why: string): void { + const notice = transcriptConsentNotice(consent, args.quiet, selection.override ? why : null) ?? transcriptConsentSummary(consent, args.quiet); if (notice) console.error(notice); } -function runMemoryIngest(args: CliArgs, previous: SyncState, consent: TranscriptConsent): StageResult { +function runMemoryIngest(args: CliArgs, previous: SyncState, consent: TranscriptPolicy): StageResult { const t0 = Date.now(); if (args.mode === "dry-run") { @@ -1529,14 +1554,19 @@ function runMemoryIngest(args: CliArgs, previous: SyncState, consent: Transcript // transcript window. const prevStage = previous.last_stages?.find((st) => st.name === "memory"); const staged = prevStage?.memory_sources ?? [...MEMORY_INGEST_TYPES]; - const prevConsent = prevStage?.transcript_consent ?? { mode: "recent", window: "recent" }; + const prevConsent: TranscriptConsentRecord = prevStage?.transcript_consent ?? { mode: "recent", window: "recent" }; if ([...staged].sort().join(",") !== [...selected].sort().join(",")) { console.error( `[sync:memory] memory source selection changed since the checkpointed run (${staged.join(",")} → ${selected.join(",")}); restaging from scratch.`, ); resume = { kind: "no-checkpoint" }; } - if (prevConsent.mode !== consentRecord.mode || prevConsent.window !== consentRecord.window) { + if ( + prevConsent.mode !== consentRecord.mode || + prevConsent.window !== consentRecord.window || + prevConsent.cutoff !== consentRecord.cutoff || + prevConsent.repos_hash !== consentRecord.repos_hash + ) { console.error( `gbrain-sync: transcript consent changed since the interrupted import (${prevConsent.mode} → ${consentRecord.mode}); restaging memory from scratch once.`, ); @@ -1565,7 +1595,7 @@ function runMemoryIngest(args: CliArgs, previous: SyncState, consent: Transcript const ingestArgs = ["run", ingestPath]; if (args.mode === "full") ingestArgs.push("--bulk"); else ingestArgs.push("--incremental"); - if (args.mode === "full" && consent.window === "all") ingestArgs.push("--all-history"); + if (args.mode === "full" && consent.affirmative && consent.window !== "recent") ingestArgs.push("--all-history"); if (args.quiet) ingestArgs.push("--quiet"); if (selection.sources) ingestArgs.push("--sources", selection.sources.join(",")); if (!args.quiet) console.error(`[sync:memory] sources: ${selected.join(",")} (${selection.why})`); @@ -2118,7 +2148,7 @@ async function main(): Promise { if (args.pruneGone) process.exit(pruneGoneWorktrees(args.mode === "dry-run")); // Read once at sync start: a config change made while the sync runs never // adds transcripts to this run. - const consent = args.noMemory ? null : normalizeTranscriptConsent(); + const consent = args.noMemory ? null : readTranscriptConsent(); if (!args.quiet) { const engine = detectEngineTier(); diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index fa5feda7c..c5c5bef0b 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -96,7 +96,15 @@ import { import { writeReceipt } from "../lib/egress-receipt"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; -import { resolveStateRoot, readConfigKeyWithRoot, type StateRootEnv } from "../lib/state-root"; +import { resolveStateRoot, mergedStateRoots, type StateRootEnv } from "../lib/state-root"; +import { + cutoffExclusion, + purgeStagedTranscripts, + readTranscriptConsent, + repoExclusion, + type TranscriptConsent, + type TranscriptPolicy, +} from "../lib/transcript-consent"; // ── Types ────────────────────────────────────────────────────────────────── @@ -167,6 +175,8 @@ interface ProbeReport { */ skipped_policy_deny: number; skipped_policy_readonly: number; + /** Sessions a scoped consent (new@ cutoff, transcript_repos) kept out. */ + scope: TranscriptScopeCounts; estimate_minutes: number; } @@ -196,6 +206,8 @@ interface BulkResult { kept_local?: number; /** The pass returned before changing anything; the state file is not rewritten. */ state_untouched?: boolean; + /** Sessions a scoped consent kept out, and transcript pages selected. */ + scope?: TranscriptScopeCounts; /** * D6: when set, indicates a process-level failure (gbrain CLI missing * or `gbrain import` crashed). Per-file errors (FILE_TOO_LARGE etc.) @@ -229,43 +241,39 @@ const ALL_TYPES: MemoryType[] = [ // ── Transcript consent ───────────────────────────────────────────────────── -export interface TranscriptConsent { - /** True only for a stored `recent` or `all`. */ - affirmative: boolean; - /** Transcript walk window when affirmative: `recent` = last 90 days, `all` = all history. */ - window: "recent" | "all" | null; - /** - * Normalized mode: the window when affirmative; otherwise why transcripts - * are skipped. `legacy` covers the old gate letters (A-E) and `incremental`, - * which predate the consent rule and need a new choice. - */ - reason: "recent" | "all" | "off" | "not-set" | "legacy" | "unrecognized"; - /** The stored value as read (null when the key is absent). */ - value: string | null; -} +export type { TranscriptConsent } from "../lib/transcript-consent"; -let cachedConsent: TranscriptConsent | null = null; +let cachedConsent: TranscriptPolicy | null = null; /** - * Read `transcript_ingest_mode` once per process (same parse as - * `gstack-config has` + `get`) and decide whether transcripts may be ingested. - * Only `recent` and `all` are consent. An absent key, `off`, a legacy value - * and anything unrecognized are not. Pass `env` to bypass the per-process cache. + * Read transcript consent once per process across every merged state root + * (lib/transcript-consent.ts). `recent`, `all` and `new@