mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
+22




![dependabot[bot]](/assets/img/avatar_default.png)
![capy-ai[bot]](/assets/img/avatar_default.png)



Garry Tan
Jim McKeeth
thearbajonart
capy-ai[bot]
dependabot[bot]
DanCanadian
Nader Helmy
Nader Helmy
Zee Zheng
Jack Thomas
Jayesh Betala
techcenter68
Mattias Petersson
Arno V
0xDevNinja
Benjamin D. Smith
Brian Majewski
neallee2012
Max Hogan
maxpetrusenkoagent
katlun-lgtm
mikehasa
Briac
JackJin
EmilianoU26
frosimanuel
Tom Lam
Yotam
Jinzhe
Xianying Tan
Boyard Engels
Jey2311
8bb6d65270
* docs: fix CONTRIBUTING.md test-tiers link in TESTING_INTERNALS The link resolved relative to docs/, pointing at a nonexistent docs/CONTRIBUTING.md. Adopts #3070. Co-authored-by: thearbajonart <334330110+thearbajonart@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci: refresh GitHub Actions SHA pins (setup-buildx 4.4.1, build-push 7.4.0, attest 4.2.2, setup-node 7.0.0) Rewrite of dependabot #2988. Every bumped `uses:` stays pinned to the full commit SHA (verified against the upstream tags) and now carries its exact version comment, including the lines that had none. setup-node 6.5.0 -> 7.0.0 is a major bump; its notes list an ESM migration, cache outputs and removal of a dummy NODE_AUTH_TOKEN export, none of which affect our node-version-only use. actionlint 1.7.11 (CI image) and 1.7.12 are clean. Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(openclaw): stop advising a per-spawn env for spawned sessions OpenClaw's sessions_spawn has no env argument (openclaw/openclaw#51654 was closed without adding one), so the documented env: { OPENCLAW_SESSION: "1" } silently did nothing. Point users at the ACP harness or Gateway environment instead and guard the old advice. Adopts #2749. Co-authored-by: DanCanadian <211164803+DanCanadian@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(benchmark): scope /benchmark routing to web page performance /benchmark claimed the bare words "benchmark" and "performance", so model and eval benchmark requests could route to the web performance skill instead of /benchmark-models. Narrow the description, Use-when list and triggers to web-specific phrases, point model/skill benchmarks at /benchmark-models, and split the root router line the same way. The catalog lead stays within the discovery budget ("Web performance ..."). Adopts #2302 and closes duplicate #2083 (rewritten on current main). Co-authored-by: Nader Helmy <31414549+creatornader@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(llms.txt): link skills by generated path, drop host-skipped skills gen-llms-txt built every skill link as <name>/SKILL.md, so the router pointed at a nonexistent gstack/SKILL.md (it is the root SKILL.md) and /claude-code pointed at a SKILL.md the Claude host never generates. Links now use the template's generated output path, and skills the Claude host skips are left out of the index. Adopts #2279. Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): install Bun from the SHA-256-verified release archive in GitLab CI and Ubicloud setup Rewrite of #1713. .gitlab-ci.yml's .setup-bun anchor and scripts/ubicloud/setup-free-suite.sh still piped https://bun.sh/install into bash. Both now download the bun-v$BUN_VERSION linux-x64 release zip, check it against the same SHA-256 Dockerfile.ci uses (#1706) before unzipping, install it to ~/.bun/bin and assert `bun --version`. test/ci-image-cli-pin.test.ts runs curlPipedToShell over both files and binds their hash and BUN_VERSION to Dockerfile.ci's amd64 arm and package.json's engines floor, so a Bun bump has to move all of them together. Verified by running the GitLab anchor in debian:stable-slim (bash and sh) and the Ubicloud block in a temp HOME. Co-authored-by: Zee Zheng <228040220+spacegeologist@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): viewport auto unpins a pinned viewport without a restart `viewport WxH` pinned the page with no way back short of `browse restart`, which also drops a headed /connect-chrome session. `viewport auto` (aliases `reset`, `unpin`) now undoes the pin: - headed: the context already launches with `viewport: null`, but Playwright cannot drop a page's emulated size (and re-applies it on new frame sessions), so the active tab gets a fresh page in the same context at the same URL or load-html content. Cookies and storage persist, the tab id and ownership stay, refs reset as on navigation, and the page follows the real window again. - headless: back to the 1280x720 launch default, tracked viewport included. `auto` with --scale is rejected. Fixes #1059. Adopts #2782 and #1881. Co-authored-by: Jack Thomas <17882510+loulanyue@users.noreply.github.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): screenshot accepts --clip=x,y,w,h and --selector=<css> Agent-generated commands often use the GNU `--flag=value` form, which threw "Unknown screenshot flag". Both value flags now accept it, through the same validation and conflict checks as the space-separated form. Adopts #1563. Co-authored-by: techcenter68 <75930234+techcenter68@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(benchmark): price Codex cached input at the cache rate, stop calling Gemini OAuth ready Codex reports cached_input_tokens as a subset of input_tokens, but the adapter billed all of input_tokens at the full input rate. Split them into disjoint uncached and cached counts (what estimateCostUsd expects) and show new+cached input in the table and Markdown reports. A stored Gemini OAuth file alone is now reported as not ready, with a reason naming GEMINI_API_KEY; key-based auth stays ready. Adopts the focused subset of #2283; the capacity error code and stdin change are left out (no captured failure or reproduced hang). Co-authored-by: Mattias Petersson <mattias.petersson@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): catch loosened-input consumers and stale user-facing strings The Enum & Value Completeness check fired only on newly added values, so a diff that widens what a validator, parser or guard accepts, and breaks an unchanged downstream consumer outside the diff, was a blind spot. Widen the check's trigger (heading unchanged) and add a consumer-grep bullet. Teach the maintainability specialist to flag user-facing strings whose guarding condition changed while the text did not. Adopts #2141 (rewritten on current main; the section heading is kept). Co-authored-by: Arno V <38716+aversini@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(global-discover): bucket Codex sessions by originator Every Codex rollout counted as one undifferentiated codex bucket, so /retro global could not tell interactive Codex Desktop work from codex_exec runs or Claude Code-driven calls. Normalize session_meta.payload.originator into desktop, exec, claude_code or other (missing, unknown and non-string values), report the split per repo as codex_originators and globally as tools.codex.originators, and print it in the summary. Existing totals are unchanged. Adopts #1488. Refs #1315. Co-authored-by: 0xDevNinja <manmit0x@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hook-check): cover skill frontmatter hooks, sourced helpers, conflict markers and the caller's tsconfig Rewrite of #3066. bin/gstack-hook-check only listed setup's hooks plus /autoplan's by name, so the /careful and /freeze PreToolUse checks (also run by /guard and /investigate), /plan-ceo-review's hand-off hook and the helpers they source were never parse-checked before Claude Code ran them. - The hook list is setup's _hook_command_path arguments plus every gstack path on a `command:` line in a generated */SKILL.md frontmatter (9 hooks, up from 6). The generated files are read rather than the .tmpl sources because the /autoplan and /plan-ceo-review commands come from resolvers. - Each shell hook's gstack-tree helpers (`.`/`source` lines, assignments and shellcheck source= directives, followed transitively) are parsed too: careful/bin/hook-extract.sh and bin/gstack-state-root.sh for /careful and /freeze. - The shim, its helpers, each TypeScript entry and every local module the bundle used (bun build --metafile; entry only when bun lacks it) are scanned for conflict-marker lines, which still parse inside heredocs, strings and template literals. - bun build runs from the gstack root, so the caller's broken tsconfig.json or bunfig.toml no longer fails every TypeScript hook. Lists are newline separated, so a root with spaces works. Tests in test/setup-hook-syntax-gate.test.ts cover each case; the gstack-doctor fixture now copies autoplan/SKILL.md, whose frontmatter registers the hook its hooks-row test breaks. docs/troubleshooting.md describes the wider check. Co-authored-by: Benjamin D. Smith <7134745+BenjaminDSmithy@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): extend defensive-security framing to the security specialist and Red Team The "authorized defensive-security review" framing and fixture summary mode lived only in the adversarial subagent prompt. The security specialist and the Red Team also reason adversarially over the full diff, so repos whose test corpus is attack payloads could still get those passes refused. Move both paragraphs into scripts/resolvers/defensive-review.ts (the adversarial render stays byte-identical), state them once in the specialist dispatch for the security specialist and the Red Team, and have the Red Team prompt start from them. Testing and the other specialists keep full fixture reads; the codex host still strips Review Army. Parity caps for ship and review rise by the measured 1,380 bytes each (carve-guards notes). Adopts #1921 (rewritten on current main). Co-authored-by: Brian Majewski <1693720+bmajewski@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(team-init): make the required-mode hook shell-neutral (node CommonJS, no shell variables) Rewrite of #2217. Required team mode wrote a bash check-gstack.sh and registered it as "$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh", which only works when the hook runner is a POSIX shell. Under cmd.exe or PowerShell (Windows, Copilot CLI) the variable is not expanded and the .sh cannot run, so an intended allow or deny became a hook error. - The hook is now .claude/hooks/check-gstack.cjs: the same root search (GSTACK_ROOT, every host's global root, the migrated repo), with the home directory from HOME, then USERPROFILE, then os.homedir(). It prints {} when gstack resolves and the PreToolUse deny JSON with exit 2 when not. - The registered command is `node -e "require(require('path').resolve( process.env.CLAUDE_PROJECT_DIR || '.', '.claude', 'hooks', 'check-gstack.cjs'))"`: no `$` or `%`, so sh, cmd.exe and PowerShell all pass it through unchanged. - An existing check-gstack registration (matcher Skill or Skill|skill) is replaced in place; duplicates are dropped and unrelated hooks in the same entry kept. The legacy .sh is deleted only after the settings write succeeds (a tracked one joins the suggested git add), and is kept when bun is missing or the write fails, which now reports instead of claiming success. Tests: team-mode and the hook-schema test run the command as registered; test/team-init-hook-shell-neutral.test.ts runs it through the platform's default shell (cmd.exe on the Windows lane, which curates it in) and through PowerShell when installed (verified here in the pwsh 7 container). The routing-probe check for the bash `_GSTACK_ROOT` gate now pins the same contract in the .cjs: every host root probed, block only when none resolves. Co-authored-by: neallee2012 <1499497+neallee2012@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup-gbrain): register the gbrain MCP through bun.exe on Windows so no console window opens Rewrite of #2007, folding #1963's MCP notes. On Windows the local stdio registration stored the bun `gbrain` binstub; gbrain.exe starts bun in a new console, so every Claude Code session opened a visible terminal for the MCP (the contributor's process trees show it; bun.exe launched directly attaches to Claude Code's headless ConPTY). Under MINGW/MSYS/Cygwin the skill now resolves BUN_ROOT from BUN_INSTALL or ~/.bun and registers `$BUN_ROOT/bin/bun.exe <global node_modules gbrain/src/cli.ts> serve`. That path also covers a `bun link`ed clone, whose global node_modules entry links to it; gstack-gbrain-install records no other clone location. When bun.exe or the entry is missing it registers `$BUN_ROOT/bin/gbrain.exe` by its full name, because Claude Code does not apply PATHEXT to a stored path (#1963). POSIX keeps the binstub line. The skill also notes that `claude mcp list` can show "Failed to connect" while a live session holds a local PGLite brain's single-writer lock (#1963). test/setup-gbrain-windows-mcp.test.ts runs the generated fence under bash with stub uname/claude and a temp HOME for MINGW64, MSYS with BUN_INSTALL, a missing entry, and Linux. It is listed as Windows-incompatible (POSIX stubs); the console behavior needs a manual Windows check. Co-authored-by: Max Hogan <max@maxhogan.dev> Co-authored-by: maxpetrusenkoagent <280464918+maxpetrusenkoagent@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hosts): rewrite every "Agent tool" phrasing for agent-runtime hosts The Agent-tool rewrite for hermes, gbrain, openclaw and factory keyed only on "use the Agent tool", but templates mostly say "via the Agent tool", "with the Agent tool", "multiple Agent tool calls" or "Use the Agent tool", so the Claude tool name leaked into those hosts' skills and sections. Add ordered rules after the existing key (possessive, "the Agent tool", then bare "Agent tool") mapping to sessions_spawn (gbrain/openclaw), delegate_task (hermes) and delegation (factory). No goldens change. Adopts #1935 (rewritten on current main, without the new goldens). Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup-deploy): Vercel auto-deploys only when connected to the Git repo /setup-deploy stated that Vercel always deploys on push, which is false for CLI-only projects with no Git integration, and its nothing-detected path did not say why such a project is invisible to detection. Qualify the Vercel trigger (ask for and record the CLI deploy command otherwise) and note that `vercel link` creates .vercel/ so a re-run detects the project. Adopts the Vercel accuracy part of #2992 (rewritten on current main); the third-party bring-up tool pointer is not adopted. Co-authored-by: mikehasa <91774998+mikehasa@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(retro): persist recommendations so the next retro detects follow-through /retro produced "3 Things to Improve" but never saved them in its JSON snapshot, so the next retro could not tell whether last week's recommendations were acted on. Step 13 now drafts the items before saving and always writes a `recommendations` array ({category, text}, [] when empty); Step 12 scores each prior recommendation addressed/partial/open against this window's commits, files and metrics and renders a follow-through line, skipping silently for snapshots without the key. The report format ties the items to the saved array. Global mode is unchanged. Adopts #1853 (rewritten on current main). Refs #1834. Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(global-discover): count the interactive Codex CLI (codex_cli_rs) in its own cli bucket * feat(developer-profile): --reconcile backfills office-hours tenure from timeline.jsonl sessions[] is written only at office-hours Phase 4.5, so runs that ended earlier never counted toward tenure and the tier undercounted. Add a manual `--reconcile` that scans projects/*/timeline.jsonl for office-hours `completed` events and appends a count-only row ({date, mode, project_slug, signal_count: 0, signals: [], backfilled: true}) for each run with no sessions[] entry for the same project within 60 minutes, extended back by a duration_s under 8h. It is idempotent, skips malformed lines, and leaves the file untouched on a no-op. `--read` counts backfilled rows for SESSION_COUNT and TIER but takes LAST_*, DESIGN_*, CROSS_PROJECT and the nudge from logged sessions only. Not wired into the preamble yet; the office-hours greeting guard for backfill-only profiles is left to the office-hours owner. Adopts #2668 (rewritten on current main). Closes part (b) of #2657. Co-authored-by: Briac <137724549+briacSck@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): the publication hook approves /autoplan's own Reads after the skill turn Claude Code applies a skill's allowed-tools only to the turn that invoked it. On 2.1.292 the default fork-subagent mode runs reviewers in the background, and the completion notice starts a new turn, so later Reads outside the working directory (the next phase's section file, the close packet) raised permission cards. Skill hooks stay registered for the session, and a PreToolUse "allow" skips the card (reproduced in a PTY on 2.1.292). autoplan/bin/owned-read.ts approves a parent Read only when the guard itself returned no output, and only for this installation's autoplan/sections/*.md (canonical path or the gbrain :user render) or an immutable phase artifact in <repo>/.gstack/tmp/autoplan/autoplan-<phase>-*/ whose repo is the session's project directory or contains it. Denials, unverified allows, child calls and every other path are unchanged. renderSectionBase and userRenderRoot move there with it. Tests that asserted a verified driver Read as {} now expect the approval; autoplan-owned-read covers the shipped shim, the close packet without a journal, verified artifacts through the guard, look-alikes (foreign install, writable copy, symlink alias, other repository, other names) and a guard denial staying a denial. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): restore point and phase artifacts live in the project's .gstack/tmp/autoplan/ Skill hooks do not run inside subagents, so a reviewer launched after a background completion notice raised a card for its own input (native-prompt.md under the state root). Step 1 now puts RESTORE_PATH in the repository's git-excluded .gstack/tmp/autoplan/ (added to .git/info/exclude, mode 700), and the snapshot tool writes every phase artifact beside it, so /autoplan and its reviewers read them from the working directory without a card. The guard's bindings are unchanged: they anchor on the restore point's directory. Step 1 still prints the slug, which the old RESTORE_PATH carried. A free test runs the generated Step 1 block twice from a repository subdirectory: the restore point lands in <repo>/.gstack/tmp/autoplan/, the exclude line is written once and git status stays clean. The autoplan parity cap rises by the measured bytes (115,270 / 101,979 = 1.1303). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): autoplan-guard-pty fails on any permission card after the reviewer's notice turn The scripted boundary now mirrors Step 1's layout (restore point and artifacts in the project's .gstack/tmp/autoplan/) and continues past the Phase 2 entry to dispatch the Phase 2 reviewer after the CEO reviewer's completion notice. The case asserts four verified guard allows, that the close packet Read, the Phase 2 section Read and the Phase 2 dispatch came after a task-notification turn, that the Phase 2 reviewer read its input, and that no permission card appeared at all. Still one trial on haiku, about 75-85 seconds. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): permission cards after a background reviewer, and where /autoplan keeps its files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(outside-review): read a trailing '— Medium.' or '(High)' severity on a finding line, the shape Codex now writes * feat(design): honor OPENAI_BASE_URL for every design API call One resolver, openaiUrl() in design/src/auth.ts, now builds every OpenAI URL the design binary, its prototype and its live model check use. Setting OPENAI_BASE_URL sends image generation and screenshot analysis to an OpenAI-compatible gateway; unset or blank keeps api.openai.com. The prototype script now goes through receiptedFetch, so its egress is receipted like the rest, and receipts already record the gateway host. A source scan keeps any design file outside auth.ts from hardcoding an OpenAI API URL again, and a request-shape test proves the gateway reaches both the vision and image endpoints. Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: testing specialist treats negative assertions as decisions A new assertion that something is absent records a behavior removal and needs cited intent. A rename or removal in the diff can empty a standing negative assertion so it passes forever; the specialist now greps the suite for negative assertions naming the old value. Adopted from the testing-specialist part of a community PR; the scope-check hunk targeted text that no longer exists. Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: security specialist checks authorization a route-guard read misses Four access-control classes where the guard is correct and the object still leaks: error pages composed outside the guard, ownership surviving a role downgrade, list/detail/download paths checked as one, and a session mutated before the identity-switch callback is validated. Adopted to review/specialists/security.md only; the /cso hunk targeted a bullet list that is now an OWASP/ASVS table. Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: zero findings is valid; suppress six common false positives The checklist now says a clean diff may come back clean, and suppresses framework-owned error handling, long exhaustive switches, null derefs a type guard already narrows, N+1 on fixed-cardinality loops, hardcoded test fixture values and non-cryptographic Math.random(). The contributor's "missing input validation on an internal function" and "missing await on fire-and-forget" suppressions are left out: both can hide real bugs. Co-authored-by: frosimanuel <frosimanuel@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(todos): parked contributor ideas by theme; /autoplan permission follow-ups * design-review: capture code tokens before universal principles /design-review read only DESIGN.md and otherwise fell back to universal principles, so a project whose system lives in CSS custom properties, tailwind.config or a theme file got audited and mocked up against an invented aesthetic. The skill now reads code tokens before falling back, and the target-mockup brief reuses the captured colors, fonts, radii and spacing, changing layout only. The contributor's optional microcopy checks (domain vocabulary, human register) are not adopted here. Co-authored-by: Tom Lam <tomlam0811@berkeley.edu> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(sidebar): add a terminal text-size picker to the toolbar The sidebar terminal toolbar gets an XS-XL text-size picker beside Restart. SM (13px) stays the default, so nothing changes until a user picks a size; the choice persists in chrome.storage.local and applies to the live terminal, re-fitting xterm and re-sending the PTY size two frames later so the new character cell is measured first. A VM test runs the real sidepanel-terminal.js through one auto-connect and checks the creation size, the stored-size fallback, persistence and the live resize. Co-authored-by: Yotam <yotsurfing@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-shotgun): generate with Codex's built-in $imagegen on Codex On the Codex host, /design-shotgun now generates and edits mockups with Codex's documented $imagegen skill in its default built-in mode (the image_gen tool), so users signed in with ChatGPT need no OPENAI_API_KEY. The design binary stays for the local comparison board only, images are published with gstack-design-claim as before, and screenshot evolution loads the captured page with view_image before an $imagegen edit. Step 3c-3d moved into a DESIGN_SHOTGUN_GENERATION resolver that returns the existing design-binary text unchanged for every other host; the Claude output differs only in two host-neutral rewordings of the evolve-from-screenshot pointers. DESIGN_SETUP and the regenerate step of the board loop gain matching Codex-only text for design-shotgun. Co-authored-by: Jinzhe <hwang.phy@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * voice: ban the "load-bearing" stock phrase Models overuse "load-bearing"; it joins the AI-vocabulary ban line in both voice tiers. The tier-2 voice directive is also tightened (the meta "compressed for runtime" tag and the bounded closer's restated exemption clause) so the shared preamble shrinks by 163 bytes per tier-2+ skill, which also pays for the reply-language rule that follows. The ship goldens are refreshed for the new render. Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * voice: reply in the language of the user's latest message Every skill's voice directive now tells the agent to reply in the language of the user's latest message unless asked otherwise, so long English skill text no longer drags a Chinese (or any non-English) session into English. Mixed-language sessions follow the latest message. Code, commands, paths, identifiers and quoted output stay verbatim, and at tier 2+ so do the machine-read question markers (D<N>, option letters, (recommended)). The rule costs 205 bytes per tier-2+ skill and 144 per tier-1 skill. A further 25-byte cut to the bounded closer's exemption list leaves the net for this wave at +17 bytes per tier-2+ skill and +158 per tier-1 skill. review, qa and browse caps rise with measured notes; the ship goldens are refreshed. Co-authored-by: Xianying Tan <shrektan@126.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(redact-prepush): committed exact-span allowlist at .gstack-redact-allowlist The pre-push hook never fed the engine's allowlist option, so a reviewed, known-benign value (a public dev-compose URL, a documented sample key) warned or blocked every push, and the only ways out were a bypass or reshaping code until the detector stopped matching. The hook now reads the repo-root .gstack-redact-allowlist from each pushed commit (never the working tree), one exact matched span per line with # comments. Only a finding whose matched text equals an entry is suppressed: a password alone, a substring, or a different key still reports and still blocks, and engine.* unscanned findings are untouched. Every push that carries entries prints the entry count and each suppressed finding's rule and file line. Oversized files (over 64 KiB) are ignored. gstack.redact.allowEmail keeps working as before. Co-authored-by: Boyard Engels <boyard.engels@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(brain-sync): opt-in removal publishing with a bulk-removal valve gstack-brain-sync never published a removal: a deleted synced file, or the old directory after a project slug migration, stayed on the remote forever and gbrain indexed both copies. New setting artifacts_sync_removals (off by default). When on, each drain commits and pushes, receipted like every push, the removal of tracked files that are gone from disk and match the sync allowlist. Transcript pages are never removed here, and a decision log goes only together with its active snapshot. One valve: a drain that would remove more than 20 files at once publishes none and says so. `gstack-brain-sync --publish-removals` lists the pending removals and `--publish-removals --yes` publishes them on demand, whatever the setting. Co-authored-by: Jey2311 <5177215+Jey2311@users.noreply.github.com> * feat(windows): setup.ps1 checks prerequisites and runs ./setup in Git Bash A PowerShell entry point for Windows users. It checks that Git for Windows, Bun and Node.js are on PATH (printing the winget command for each missing one and changing nothing), finds Git Bash from git.exe rather than a PATH bash that may be WSL's, and runs ./setup from the checkout with every argument passed as its own argv entry to a constant bash script. No argument is interpolated into shell source. ./setup itself is unchanged. Co-authored-by: Jim McKeeth <jim@mckeeth.org> * v1.91.63.0: CHANGELOG, version * fix(ci): link bunx after the SHA-verified Bun install in GitLab CI and the Ubicloud setup * fix(redact-prepush): strip every parent column from combined-diff lines, so a merged release bump is exempt like any other * fix(hook-check): treat a Windows drive path as absolute (cygpath when present), so --list finds the skill hooks from a native caller * fix(windows): owned-read requires the read-only attribute on Windows; setup.ps1 passes arguments NUL-separated through a private file; Windows-only tests expect the owned-read approval * perf(hook-check): bundle every hook's TypeScript entries in one bun process instead of two bun starts per entry (seconds each on Windows) * perf(hook-check): read all skill frontmatter in one awk and scan conflict markers with one grep per hook (half the process starts) --------- Co-authored-by: thearbajonart <334330110+thearbajonart@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: DanCanadian <211164803+DanCanadian@users.noreply.github.com> Co-authored-by: Nader Helmy <31414549+creatornader@users.noreply.github.com> Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: Zee Zheng <228040220+spacegeologist@users.noreply.github.com> Co-authored-by: Jack Thomas <17882510+loulanyue@users.noreply.github.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: techcenter68 <75930234+techcenter68@users.noreply.github.com> Co-authored-by: Mattias Petersson <mattias.petersson@gmail.com> Co-authored-by: Arno V <38716+aversini@users.noreply.github.com> Co-authored-by: 0xDevNinja <manmit0x@gmail.com> Co-authored-by: Benjamin D. Smith <7134745+BenjaminDSmithy@users.noreply.github.com> Co-authored-by: Brian Majewski <1693720+bmajewski@users.noreply.github.com> Co-authored-by: neallee2012 <1499497+neallee2012@users.noreply.github.com> Co-authored-by: Max Hogan <max@maxhogan.dev> Co-authored-by: maxpetrusenkoagent <280464918+maxpetrusenkoagent@users.noreply.github.com> Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com> Co-authored-by: mikehasa <91774998+mikehasa@users.noreply.github.com> Co-authored-by: Briac <137724549+briacSck@users.noreply.github.com> Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: frosimanuel <frosimanuel@gmail.com> Co-authored-by: Tom Lam <tomlam0811@berkeley.edu> Co-authored-by: Yotam <yotsurfing@gmail.com> Co-authored-by: Jinzhe <hwang.phy@gmail.com> Co-authored-by: Xianying Tan <shrektan@126.com> Co-authored-by: Boyard Engels <boyard.engels@gmail.com> Co-authored-by: Jey2311 <5177215+Jey2311@users.noreply.github.com> Co-authored-by: Jim McKeeth <jim@mckeeth.org>
1601 lines
74 KiB
Bash
Executable File
1601 lines
74 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# gstack-brain-sync — drain queue, commit allowlisted paths, push to remote.
|
||
#
|
||
# Usage:
|
||
# gstack-brain-sync --once drain queue, commit, push (default)
|
||
# gstack-brain-sync --status print sync health as JSON
|
||
# gstack-brain-sync --skip-file <p> permanently exclude <p> (and any coupled
|
||
# group it belongs to) from future syncs
|
||
# gstack-brain-sync --unskip-file <p> undo --skip-file and queue <p> again
|
||
# gstack-brain-sync --drop-queue --yes clear queue without committing
|
||
# gstack-brain-sync --discover-new scan allowlist dirs, enqueue changed files
|
||
# gstack-brain-sync --attention print skill start's attention lines
|
||
# gstack-brain-sync --publish-removals [--yes]
|
||
# list (or, with --yes, commit and push)
|
||
# the removal of synced files gone from disk
|
||
#
|
||
# Invoked by the preamble at skill START and END boundaries. No persistent
|
||
# daemon. Typical run <1s when queue empty; ~200-800ms with network push.
|
||
#
|
||
# One drain at a time: the drain lock (bin/gstack-brain-lock.sh) is shared
|
||
# with every other gstack writer to ~/.gstack's git state. A drain that finds
|
||
# it held skips; the next skill boundary catches up.
|
||
#
|
||
# Removals: with artifacts_sync_removals=on, each drain also publishes the
|
||
# removal of synced files that are gone from disk (a deleted design doc, the
|
||
# old directory after a project slug migration), so the remote and the brain
|
||
# stop serving stale copies. A drain that would remove more than
|
||
# REMOVAL_VALVE files at once publishes none of them and says so: that many
|
||
# at once looks like a loss, not a cleanup. --publish-removals --yes publishes
|
||
# them on demand, whatever the setting.
|
||
#
|
||
# A file the secret scan flags is held back on its own (with the rest of its
|
||
# coupled group) and stays queued; everything else syncs. `--status` lists
|
||
# each held file, the scanner rule that matched (never the matched text) and
|
||
# both fixes.
|
||
#
|
||
# Sync status table. Skill start prints the attention line of every row that
|
||
# applies; `ok` and `idle` print none. <bin> is this script's absolute path
|
||
# and <N> the held count. Rows are read by test/gstack-skill-start.test.ts.
|
||
#
|
||
# held trigger: status=held: the secret scan held back flagged files and synced the rest
|
||
# line: ARTIFACTS_SYNC: attention: status=held held=<N>. The secret scan is holding back <N> file(s); everything else still syncs. See which files and how to fix them: <bin> --status
|
||
# fix: edit the file so the scan no longer matches, or <bin> --skip-file <path> (permanent; undo with <bin> --unskip-file <path>)
|
||
# blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed
|
||
# line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: <bin> --status
|
||
# fix: edit or skip the files named by <bin> --status, or clear the batch with <bin> --drop-queue --yes
|
||
# push_failed trigger: status=push_failed: a commit is saved locally but the push failed
|
||
# line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: <bin> --status
|
||
# fix: the fix <bin> --status names (usually: gh auth status)
|
||
# error trigger: status=error: the drain stopped before committing and kept the queue
|
||
# line: ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: <bin> --status
|
||
# fix: the cause <bin> --status names (git index lock, commit hook, git add failure)
|
||
# unknown trigger: the status file holds a status code outside this table
|
||
# line: ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: <bin> --status
|
||
# fix: run <bin> --once to rewrite it
|
||
# stale-push trigger: no push for 24 hours while the drain's drainable count is above zero
|
||
# line: ARTIFACTS_SYNC: attention: stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: <bin> --status
|
||
# fix: the waiting reason <bin> --status names (a coupled group waiting for a consistent generation, a git add failure)
|
||
# stale-drain trigger: last_drain_at older than 24 hours while the queue directory holds records
|
||
# line: ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: <bin> --status
|
||
# fix: if no gstack process is running, remove ~/.gstack/.brain-sync.lock.d, then run <bin> --once
|
||
#
|
||
# Env:
|
||
# GSTACK_HOME — override ~/.gstack (aligns with writers).
|
||
|
||
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||
# pipe in the forked child before exec, with no reader on the other end. On
|
||
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
||
# body >=512B blocks write() forever and the script hangs at startup with no
|
||
# output. Compat level 50 restores the tempfile path. These scripts are
|
||
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
||
# guard is per-script, and it survives `bash script.sh` call sites that
|
||
# bypass the shebang.
|
||
BASH_COMPAT=50
|
||
|
||
set -uo pipefail
|
||
|
||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||
gstack_state_root_select
|
||
GSTACK_HOME="$_gstack_sr_root"
|
||
# Maildir-style spool: one FILE per record, <epoch>-<pid>-<uniq>.json.
|
||
# Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file
|
||
# + atomic rename; the drain deletes exactly the files it snapshotted. No
|
||
# shared inode between writer and drainer → no append/rewrite race.
|
||
QUEUE_DIR="$GSTACK_HOME/.brain-queue.d"
|
||
# Legacy single-file queue: kept ONLY for migration. Pre-spool writers
|
||
# appended lines here; migrate_legacy_queue converts them to spool files.
|
||
QUEUE="$GSTACK_HOME/.brain-queue.jsonl"
|
||
ALLOWLIST="$GSTACK_HOME/.brain-allowlist"
|
||
PRIVACY_MAP="$GSTACK_HOME/.brain-privacy-map.json"
|
||
SKIP_FILE="$GSTACK_HOME/.brain-skip.txt"
|
||
STATUS_FILE="$GSTACK_HOME/.brain-sync-status.json"
|
||
LAST_PUSH_FILE="$GSTACK_HOME/.brain-last-push"
|
||
DISCOVER_CURSOR="$GSTACK_HOME/.brain-discover-cursor"
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
|
||
SELF_BIN="$SCRIPT_DIR/gstack-brain-sync"
|
||
|
||
# Egress receipt helpers (_receipted_git): receipt-before-send, fail-closed.
|
||
. "$SCRIPT_DIR/gstack-egress-lib.sh"
|
||
# The drain lock shared with every gstack writer to the artifacts repo.
|
||
. "$SCRIPT_DIR/gstack-brain-lock.sh"
|
||
|
||
# Coupled groups: artifacts that reference each other and must reach the
|
||
# remote as one generation. Members are globs sharing one "*" segment (the same
|
||
# value binds every member); `check` names the consistency test the staged
|
||
# generation must pass before it is committed. Paths not listed here are
|
||
# independent.
|
||
COUPLED_GROUPS='[{"name": "decisions", "members": ["projects/*/decisions.jsonl", "projects/*/decisions.active.json"], "check": "decisions-active"}]'
|
||
|
||
REMOVAL_VALVE=20
|
||
|
||
# Fields every status write of this drain carries (see write_status).
|
||
ST_HELD='[]'
|
||
ST_DRAINABLE=0
|
||
ST_NOTE=""
|
||
|
||
# origin host for receipt records (github.com etc).
|
||
remote_host() {
|
||
local url host
|
||
url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "")
|
||
host="${url#*://}"; host="${host#*@}"; host="${host%%[/:]*}"
|
||
echo "${host:-unknown}"
|
||
}
|
||
|
||
# Remote-specific hint for auth errors (branch on origin URL).
|
||
remote_auth_hint() {
|
||
local url
|
||
url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "")
|
||
case "$url" in
|
||
*github.com*|*@github.*) echo "run: gh auth status (and gh auth refresh if needed)" ;;
|
||
*gitlab*) echo "run: glab auth status" ;;
|
||
*) echo "check 'git remote -v' and your credentials" ;;
|
||
esac
|
||
}
|
||
|
||
# Status file schema (one JSON object; readers: --status, --attention, doctor):
|
||
# status ok | idle | held | blocked | push_failed | error
|
||
# held_count files the secret scan held back this drain (hits + dependents)
|
||
# drainable queued files a drain could publish now: excludes scanner-held
|
||
# and privacy-held records
|
||
# last_drain_at UTC time this drain ran (every write happens inside a drain)
|
||
# last_push_at UTC time of the last successful push, or null
|
||
# ts same as last_drain_at (kept for older readers)
|
||
# held [{path, rule, dependents[], fixes[]}]: one entry per flagged
|
||
# path; rule is the scanner rule name, never the matched text
|
||
# message human detail for --status; never printed at skill start
|
||
# Only status, the counts and the timestamps feed skill start's attention
|
||
# lines, each clamped to a closed set, digits, or a timestamp shape.
|
||
write_status() {
|
||
local code="$1" msg="$2${ST_NOTE}" ts last_push=""
|
||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
|
||
[ -f "$LAST_PUSH_FILE" ] && last_push=$(head -1 "$LAST_PUSH_FILE" 2>/dev/null || echo "")
|
||
python3 - "$STATUS_FILE" "$code" "$msg" "$ts" "$last_push" "$ST_DRAINABLE" "$ST_HELD" <<'PYEOF' 2>/dev/null || true
|
||
import json, re, sys
|
||
path, code, msg, ts, last_push, drainable, held = sys.argv[1:8]
|
||
try:
|
||
held = [h for h in json.loads(held) if isinstance(h, dict)]
|
||
except Exception:
|
||
held = []
|
||
counted = set()
|
||
for h in held:
|
||
counted.add(h.get("path"))
|
||
counted.update(h.get("dependents") or [])
|
||
last_push = last_push.strip()
|
||
data = {
|
||
"status": code,
|
||
"held_count": len(counted),
|
||
"drainable": int(drainable) if drainable.isdigit() else 0,
|
||
"last_drain_at": ts,
|
||
"last_push_at": last_push if re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", last_push) else None,
|
||
"ts": ts,
|
||
"held": held,
|
||
"message": msg,
|
||
}
|
||
with open(path, "w") as f:
|
||
json.dump(data, f)
|
||
f.write("\n")
|
||
PYEOF
|
||
}
|
||
|
||
# Read config; return 0 if sync active, 1 otherwise.
|
||
sync_active() {
|
||
if [ ! -d "$GSTACK_HOME/.git" ]; then
|
||
return 1
|
||
fi
|
||
local mode
|
||
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
|
||
[ "$mode" = "off" ] && return 1
|
||
return 0
|
||
}
|
||
|
||
# Secret scan over a staged diff file. Pattern families are the
|
||
# pre-commit hook's; only the family name ever leaves this function, never the
|
||
# matched text. The diff is read as bytes and decoded with replacement, so
|
||
# undecodable bytes cannot crash the scan into a false "clean".
|
||
# secret_scan gate <diff> exit 0 clean, 3 hit (prints the
|
||
# family names); anything else is
|
||
# a scanner failure (fail closed)
|
||
# secret_scan attribute <diff> <names> <out> one pass: splits the diff on its
|
||
# `diff --git` headers, pairs each
|
||
# section with the NUL-separated
|
||
# names from `git diff --name-only
|
||
# -z` (same order), writes
|
||
# "<family>\t<path>" per flagged
|
||
# path; exit 0, or 4 when sections
|
||
# and names disagree
|
||
secret_scan() {
|
||
python3 - "$@" <<'PYEOF'
|
||
import re, sys
|
||
patterns = [
|
||
('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')),
|
||
('github-token', re.compile(r'\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')),
|
||
('openai-key', re.compile(r'\bsk-[A-Za-z0-9_-]{20,}')),
|
||
('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')),
|
||
('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b')),
|
||
('bearer-token-json',
|
||
# JSON-embedded auth headers. The optional Bearer/Basic/Token prefix
|
||
# matters: real auth values include a literal space after the scheme
|
||
# name, but the value charset below does not include spaces, so
|
||
# without the optional prefix every Bearer token in a JSON blob slips
|
||
# past the scanner.
|
||
re.compile(r'"(authorization|api[_-]?key|apikey|token|secret|password)"\s*:\s*"(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}"',
|
||
re.IGNORECASE)),
|
||
]
|
||
|
||
def families(text):
|
||
return [name for name, rx in patterns if rx.search(text)]
|
||
|
||
def read(path):
|
||
with open(path, "rb") as f:
|
||
return f.read()
|
||
|
||
mode = sys.argv[1]
|
||
if mode == "gate":
|
||
hits = families(read(sys.argv[2]).decode("utf-8", "replace"))
|
||
if hits:
|
||
print(",".join(hits))
|
||
sys.exit(3)
|
||
sys.exit(0)
|
||
|
||
diff_path, names_path, out_path = sys.argv[2:5]
|
||
names = [n.decode("utf-8", "surrogateescape") for n in read(names_path).split(b"\0") if n]
|
||
sections = []
|
||
for line in read(diff_path).split(b"\n"):
|
||
if line.startswith(b"diff --git "):
|
||
sections.append([])
|
||
elif sections:
|
||
sections[-1].append(line)
|
||
if len(sections) != len(names):
|
||
sys.exit(4)
|
||
with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out:
|
||
for name, body in zip(names, sections):
|
||
if "\n" in name or "\t" in name:
|
||
sys.exit(4)
|
||
hits = families(b"\n".join(body).decode("utf-8", "replace"))
|
||
if hits:
|
||
out.write(hits[0] + "\t" + name + "\n")
|
||
PYEOF
|
||
}
|
||
|
||
# True (0) if the spool holds at least one record file.
|
||
spool_has_records() {
|
||
local f
|
||
for f in "$QUEUE_DIR"/*.json; do
|
||
[ -e "$f" ] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Convert one legacy queue file's lines into spool record files (tmp +
|
||
# os.replace, one file per line). Reads the file TWICE before unlinking: a
|
||
# pre-rename writer can still append through its already-open fd after our
|
||
# rename, and those appends land in the renamed file — the second pass
|
||
# NARROWS the tail-race window (transition-only: it applies to pre-spool
|
||
# writers, and a writer that appends after the second read but before the
|
||
# unlink can still lose that line; spool-native writers are immune).
|
||
# Unparseable lines migrate as-is; finalize_queue quarantines + warns on them.
|
||
convert_legacy_file() {
|
||
local legacy="$1"
|
||
python3 - "$legacy" "$QUEUE_DIR" <<'PYEOF' 2>/dev/null || true
|
||
import os, sys, time
|
||
|
||
legacy, spool = sys.argv[1:3]
|
||
|
||
def read_lines(path):
|
||
try:
|
||
with open(path) as f:
|
||
return [l.rstrip("\r\n") for l in f if l.strip()]
|
||
except (FileNotFoundError, OSError):
|
||
return []
|
||
|
||
seq = 0
|
||
def write_spool(line):
|
||
global seq
|
||
seq += 1
|
||
tmp = os.path.join(spool, f".tmp-{os.getpid()}-m{seq}")
|
||
with open(tmp, "w") as f:
|
||
f.write(line + "\n")
|
||
os.replace(tmp, os.path.join(spool, f"{int(time.time())}-{os.getpid()}-m{seq}.json"))
|
||
|
||
written = set()
|
||
for _pass in (1, 2): # second read closes the pre-rename-fd tail race
|
||
for line in read_lines(legacy):
|
||
if line not in written: # identical duplicates collapse, as the old rewrite did
|
||
write_spool(line)
|
||
written.add(line)
|
||
os.unlink(legacy)
|
||
PYEOF
|
||
}
|
||
|
||
# Legacy migration (transition window only). If the single-file queue holds
|
||
# records, atomically rename it aside and convert each line to a spool file.
|
||
# A concurrent OLD writer that recreates a fresh legacy file after the rename
|
||
# simply gets migrated on the NEXT drain — nothing is lost, only deferred one
|
||
# boundary. Runs inside the run lock, before the drain reads the spool.
|
||
migrate_legacy_queue() {
|
||
local migrating="$QUEUE.migrating"
|
||
# Crash leftover: a prior migration renamed but died before unlink. Some of
|
||
# its lines may already exist as spool files — re-converting duplicates is
|
||
# safe (at-least-once; the drain dedups paths per snapshot and downstream
|
||
# content-hash dedup absorbs re-syncs). Losing the file would not be. If
|
||
# the conversion itself fails, the file stays for the next run (never rm a
|
||
# non-empty .migrating file outside convert_legacy_file's own unlink).
|
||
if [ -f "$migrating" ]; then
|
||
if [ -s "$migrating" ]; then
|
||
mkdir -p "$QUEUE_DIR" 2>/dev/null || return 0
|
||
convert_legacy_file "$migrating"
|
||
else
|
||
rm -f "$migrating" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
# If the leftover STILL holds records, the conversion failed (e.g. python3
|
||
# unavailable). The mv below would overwrite it and destroy those records —
|
||
# exactly the never-destroy invariant above. Defer this run's migration;
|
||
# the next run retries both files.
|
||
[ -s "$migrating" ] && return 0
|
||
if [ -s "$QUEUE" ]; then
|
||
mkdir -p "$QUEUE_DIR" 2>/dev/null || return 0
|
||
mv -f "$QUEUE" "$migrating" 2>/dev/null || return 0
|
||
convert_legacy_file "$migrating"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# Compute matched allowlisted, privacy-filtered path set from the spool.
|
||
# Output: newline-delimited relative paths that should be staged.
|
||
#
|
||
# #2549: every non-staged queue entry is CLASSIFIED, never silently discarded.
|
||
# When $2 is given, a JSON classification lands there:
|
||
# {"retained": [privacy/mode-held paths that stay queued],
|
||
# "dropped": {"skipped": [...], "invalid": [...], "unmatched": [...], "missing": [...]}}
|
||
# retained entries would sync if the user raises artifacts_sync_mode, so they
|
||
# stay in the queue; dropped classes can never sync (explicit skip, escape
|
||
# attempt, no allowlist glob, not on disk) and are removed WITH a counted
|
||
# status — the old behavior truncated the whole queue and reported every one
|
||
# of these, including privacy holds, as "no allowlisted changes".
|
||
#
|
||
# Spool snapshot ($3): the sorted list of spool record filenames read here is
|
||
# written to the snapshot manifest, one filename per line. finalize_queue
|
||
# deletes exactly the manifest's files and never touches records created
|
||
# after this listing — a concurrent enqueue is a separate file by
|
||
# construction, so it simply rides to the next drain.
|
||
#
|
||
# Coupled groups ($COUPLED_GROUPS): a stageable path pulls in the rest of its
|
||
# group (each member that exists, is allowlisted and passes the privacy mode),
|
||
# so a generation is staged whole. A skipped member drops the whole group
|
||
# (skip is per group, so the remote never gets a partial generation); a
|
||
# privacy-held member retains the whole group. When $5 is given, the groups
|
||
# with a staged member land there as JSON:
|
||
# [{"name", "check", "members": [all member paths], "staged": [...]}]
|
||
compute_paths_to_stage() {
|
||
local mode="$1"
|
||
local class_file="${2:-}"
|
||
local snapshot_file="${3:-}"
|
||
local transcripts_ok="${4:-0}"
|
||
local groups_file="${5:-}"
|
||
python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" "$groups_file" "$COUPLED_GROUPS" <<'PYEOF'
|
||
import sys, json, os, fnmatch, glob, re
|
||
|
||
gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file, transcripts_ok, groups_file, coupled = sys.argv[1:12]
|
||
|
||
def load_lines(path):
|
||
try:
|
||
with open(path) as f:
|
||
return [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")]
|
||
except FileNotFoundError:
|
||
return []
|
||
|
||
def load_privacy_map(path):
|
||
# Returns (entries, corrupt). Non-dict entries are filtered out
|
||
# defensively — the map may be PULLED from the artifacts remote, so a
|
||
# malformed entry like ["bad"] is remotely triggerable and used to raise
|
||
# mid-classification (after the snapshot manifest was written), which the
|
||
# old finalize turned into a full queue wipe. Any malformed shape also
|
||
# marks the map CORRUPT: privacy classification cannot be trusted, so the
|
||
# caller holds every queued record instead of guessing (a corrupt privacy
|
||
# map silently treated as empty would over-share behavioral data).
|
||
try:
|
||
with open(path) as f:
|
||
data = json.load(f)
|
||
except FileNotFoundError:
|
||
return [], False
|
||
except json.JSONDecodeError:
|
||
return [], True
|
||
if not isinstance(data, list):
|
||
return [], True
|
||
# Expected: [{"pattern": "glob", "class": "artifact" | "behavioral"}]
|
||
entries = [e for e in data if isinstance(e, dict)]
|
||
return entries, len(entries) != len(data)
|
||
|
||
allowlist_globs = load_lines(allowlist_path)
|
||
privacy_map, privacy_corrupt = load_privacy_map(privacy_path)
|
||
# Normalize skip entries to the POSIX form queued paths use, so a backslash
|
||
# entry in .brain-skip.txt still matches on Windows. The drain is the safety
|
||
# boundary that actually stages files, so it must normalize identically to
|
||
# discover_new — otherwise an explicitly-skipped file gets committed.
|
||
skip_lines = {s.replace(os.sep, "/") for s in load_lines(skip_path)}
|
||
|
||
# Snapshot the spool: sorted (= chronological, filenames are epoch-first)
|
||
# list of record files at read time. Records that appear after this listing
|
||
# belong to the NEXT drain. Files we cannot read stay OUT of the manifest so
|
||
# finalize never deletes a record this drain didn't actually consume.
|
||
try:
|
||
snapshot = sorted(n for n in os.listdir(spool_dir) if n.endswith(".json"))
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
snapshot = []
|
||
|
||
queue_paths = set()
|
||
consumed = []
|
||
for name in snapshot:
|
||
try:
|
||
with open(os.path.join(spool_dir, name)) as f:
|
||
line = f.readline().strip()
|
||
except OSError:
|
||
continue
|
||
consumed.append(name)
|
||
if not line:
|
||
continue
|
||
try:
|
||
obj = json.loads(line)
|
||
p = obj.get("file")
|
||
if isinstance(p, str):
|
||
queue_paths.add(p)
|
||
except json.JSONDecodeError:
|
||
continue # unparseable record: finalize keeps + warns
|
||
|
||
if snapshot_file:
|
||
with open(snapshot_file, "w") as f:
|
||
for name in consumed:
|
||
f.write(name + "\n")
|
||
|
||
def path_matches_any(path, globs):
|
||
for pattern in globs:
|
||
if fnmatch.fnmatchcase(path, pattern):
|
||
return True
|
||
return False
|
||
|
||
def privacy_class(path, mapping):
|
||
for entry in mapping:
|
||
pat = entry.get("pattern")
|
||
if pat and fnmatch.fnmatchcase(path, pat):
|
||
return entry.get("class", "artifact")
|
||
# Default class when no pattern matches: artifact (safe default).
|
||
return "artifact"
|
||
|
||
# mode filter: 'off' → nothing; 'artifacts-only' → only artifact class;
|
||
# 'full' → both classes.
|
||
def mode_allows(cls, mode):
|
||
if mode == "off":
|
||
return False
|
||
if mode == "artifacts-only":
|
||
return cls == "artifact"
|
||
return True # full
|
||
|
||
def group_of(path):
|
||
# (group spec, member paths) when path is a member of a coupled group.
|
||
for spec in json.loads(coupled):
|
||
for member in spec["members"]:
|
||
m = re.fullmatch(re.escape(member).replace(r"\*", "([^/]+)"), path)
|
||
if m:
|
||
return spec, [x.replace("*", m.group(1), 1) for x in spec["members"]]
|
||
return None, []
|
||
|
||
final = []
|
||
groups = {}
|
||
classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}}
|
||
if privacy_corrupt:
|
||
# Fail-safe: with an untrustworthy privacy map, stage NOTHING and drop
|
||
# NOTHING — retain every queued record until the map is fixed. The next
|
||
# drain re-classifies from scratch.
|
||
print("BRAIN_SYNC: warning: privacy map at " + privacy_path +
|
||
" is malformed — holding all queued records until it is fixed", file=sys.stderr)
|
||
classified["retained"] = sorted(queue_paths)
|
||
queue_paths = set()
|
||
for p in sorted(queue_paths):
|
||
spec, members = group_of(p)
|
||
if p in skip_lines or any(m in skip_lines for m in members):
|
||
classified["dropped"]["skipped"].append(p)
|
||
continue
|
||
# Must be under GSTACK_HOME root. Reject absolute + reject ../ escape.
|
||
if p.startswith("/") or ".." in p.split("/"):
|
||
classified["dropped"]["invalid"].append(p)
|
||
continue
|
||
# Must match at least one allowlist glob.
|
||
if not path_matches_any(p, allowlist_globs):
|
||
classified["dropped"]["unmatched"].append(p)
|
||
continue
|
||
# Staged transcript pages need transcript consent; without it they stay
|
||
# queued (retained) and on disk, and push once the user consents.
|
||
if transcripts_ok != "1" and p.split("/")[0] == "transcripts":
|
||
classified["retained"].append(p)
|
||
continue
|
||
# Must survive privacy mode filter — held entries STAY QUEUED (retained):
|
||
# they would sync under a higher artifacts_sync_mode, and reporting them
|
||
# as "no allowlisted changes" was #2549's misattribution.
|
||
cls = privacy_class(p, privacy_map)
|
||
if not mode_allows(cls, mode):
|
||
classified["retained"].append(p)
|
||
continue
|
||
# Must exist on disk — can't stage what isn't there.
|
||
if not os.path.exists(os.path.join(gstack_home, p)):
|
||
classified["dropped"]["missing"].append(p)
|
||
continue
|
||
siblings = [m for m in members if m != p
|
||
and path_matches_any(m, allowlist_globs)
|
||
and os.path.exists(os.path.join(gstack_home, m))]
|
||
if any(not mode_allows(privacy_class(m, privacy_map), mode) for m in siblings):
|
||
classified["retained"].append(p)
|
||
continue
|
||
for m in [p] + siblings:
|
||
if m not in final:
|
||
final.append(m)
|
||
if spec:
|
||
groups.setdefault(members[0], {"name": spec["name"], "check": spec.get("check", ""),
|
||
"members": members, "staged": []})
|
||
|
||
for g in groups.values():
|
||
g["staged"] = [m for m in g["members"] if m in final]
|
||
|
||
if class_file:
|
||
with open(class_file, "w") as f:
|
||
json.dump(classified, f)
|
||
if groups_file:
|
||
with open(groups_file, "w") as f:
|
||
json.dump(list(groups.values()), f)
|
||
# `git cat-file --batch` input for every member's index entry: the
|
||
# generation check reads exactly the bytes a commit would contain.
|
||
with open(groups_file + ".batch", "w", newline="\n") as f:
|
||
for g in groups.values():
|
||
for m in g["members"]:
|
||
f.write(":" + m + "\n")
|
||
|
||
for p in final:
|
||
print(p)
|
||
PYEOF
|
||
}
|
||
|
||
# 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() {
|
||
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
|
||
# consumed (per the snapshot manifest) AND positively classified. Deletion is
|
||
# EXPLICIT-DELETE-ONLY: a record is unlinked only when its path appears in
|
||
# (staged paths ∪ classified dropped). The old polarity ("delete unless
|
||
# retained") turned a missing/unparseable classification into retained=∅ and
|
||
# wiped every snapshotted record — remotely triggerable via a malformed
|
||
# pulled privacy map that raised AFTER the manifest write. Now a
|
||
# missing/unparseable class_file or paths_file deletes NOTHING (warn +
|
||
# return), and a path the classification never mentions stays queued.
|
||
# The predecessor (a shared-file queue rewrite) had a lockless-append race
|
||
# between its live re-read and the os.replace; with one file per record that
|
||
# race class is structurally gone — a concurrent enqueue is a separate file
|
||
# the snapshot never listed, so finalize cannot touch it. Crash semantics are
|
||
# at-least-once: a drain that dies before finalize leaves its spool files in
|
||
# place and the next run re-drains them; downstream content-hash dedup
|
||
# absorbs the duplicates. Unparseable records move to $QUEUE_DIR/quarantine/
|
||
# (never deleted) so they stop re-warning at every boundary. Dropped-path
|
||
# detail goes to a 0600 sidecar so the status line can stay content-free
|
||
# (counts only).
|
||
finalize_queue() {
|
||
local snapshot_file="$1" # spool filenames this drain consumed, one per line
|
||
local class_file="$2" # classification JSON from compute_paths_to_stage
|
||
local paths_file="$3" # staged paths (compute_paths_to_stage stdout), one per line
|
||
# Fail-open by design (a failed finalize self-corrects next run: re-stage →
|
||
# nothing-to-commit), but say so — a silent failure here would let the
|
||
# subsequent "ok/idle" status claim a drain that did not happen.
|
||
python3 - "$QUEUE_DIR" "$snapshot_file" "$class_file" "$paths_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue finalize failed — entries retained; next run re-drains" >&2
|
||
import json, os, sys, time
|
||
spool_dir, snapshot_file, class_file, paths_file, drops_file = sys.argv[1:6]
|
||
|
||
def lines(path):
|
||
try:
|
||
with open(path) as f:
|
||
return [l.rstrip("\r\n") for l in f if l.strip()]
|
||
except FileNotFoundError:
|
||
return []
|
||
|
||
# Explicit-delete-only inputs. Either input unreadable → delete NOTHING.
|
||
try:
|
||
with open(class_file) as f:
|
||
classified = json.load(f)
|
||
if not isinstance(classified, dict):
|
||
raise ValueError("classification is not an object")
|
||
except Exception:
|
||
print("BRAIN_SYNC: warning: classification unreadable — no queue records deleted; next run re-drains", file=sys.stderr)
|
||
sys.exit(0)
|
||
try:
|
||
with open(paths_file) as f:
|
||
staged = {l.strip() for l in f if l.strip()}
|
||
except Exception:
|
||
print("BRAIN_SYNC: warning: staged-paths file unreadable — no queue records deleted; next run re-drains", file=sys.stderr)
|
||
sys.exit(0)
|
||
|
||
dropped = set()
|
||
for group in (classified.get("dropped", {}) or {}).values():
|
||
dropped.update(group)
|
||
deletable = staged | dropped
|
||
# A record that stays queued (privacy-held, scanner-held, waiting group) only
|
||
# needs one copy per path: every re-enqueue of such a path used to add a record
|
||
# that was never consumed, so the queue count grew without meaning anything.
|
||
kept = {}
|
||
|
||
unparseable = 0
|
||
for name in lines(snapshot_file):
|
||
full = os.path.join(spool_dir, name)
|
||
try:
|
||
with open(full) as f:
|
||
rec = f.readline().strip()
|
||
except OSError:
|
||
continue # unreadable now: leave it for the next drain
|
||
p = None
|
||
try:
|
||
p = json.loads(rec).get("file")
|
||
except Exception:
|
||
pass
|
||
if not isinstance(p, str):
|
||
# Never destroy what we can't read — but don't leave it re-warning at
|
||
# every boundary either: move it aside for inspection.
|
||
unparseable += 1
|
||
try:
|
||
qdir = os.path.join(spool_dir, "quarantine")
|
||
os.makedirs(qdir, exist_ok=True)
|
||
os.replace(full, os.path.join(qdir, name))
|
||
except OSError:
|
||
pass # quarantine move failed — leave in place; next run retries
|
||
continue
|
||
if p not in deletable:
|
||
kept.setdefault(p, []).append(name)
|
||
continue # retained / unclassified: stays queued (explicit-delete-only)
|
||
try:
|
||
os.unlink(full) # staged or dropped: fully processed
|
||
except FileNotFoundError:
|
||
pass
|
||
def age_key(name):
|
||
head = name.split("-", 1)[0]
|
||
return (int(head) if head.isdigit() else 0, name)
|
||
for names in kept.values():
|
||
for older in sorted(names, key=age_key)[:-1]:
|
||
try:
|
||
os.unlink(os.path.join(spool_dir, older)) # the newest record carries the path
|
||
except OSError:
|
||
pass
|
||
if unparseable:
|
||
print(f"BRAIN_SYNC: {unparseable} unparseable spool record(s) moved to quarantine (inspect {os.path.join(spool_dir, 'quarantine')})", file=sys.stderr)
|
||
|
||
if dropped:
|
||
fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||
with os.fdopen(fd, "w") as f:
|
||
json.dump({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
"dropped": classified.get("dropped", {})}, f)
|
||
PYEOF
|
||
}
|
||
|
||
# Human-readable classification counts for status messages.
|
||
queue_summary() {
|
||
local class_file="$1"
|
||
python3 - "$class_file" <<'PYEOF' 2>/dev/null || echo ""
|
||
import json, sys
|
||
try:
|
||
with open(sys.argv[1]) as f:
|
||
c = json.load(f)
|
||
except Exception:
|
||
print(""); sys.exit(0)
|
||
d = c.get("dropped", {}) or {}
|
||
parts = []
|
||
r = len(c.get("retained", []))
|
||
if r: parts.append(f"{r} privacy-held retained")
|
||
for k in ("skipped", "unmatched", "missing", "invalid"):
|
||
n = len(d.get(k, []))
|
||
if n: parts.append(f"{n} {k} dropped")
|
||
print("; ".join(parts))
|
||
PYEOF
|
||
}
|
||
|
||
# Remove .git/index.lock when it is older than 10 minutes. Call only while
|
||
# holding the drain lock: every gstack writer takes that lock before running
|
||
# git here, so an old index lock is one a killed git process left behind.
|
||
# Returns 1 when a younger index lock is present (a live git process).
|
||
clear_stale_index_lock() {
|
||
local idx="$GSTACK_HOME/.git/index.lock"
|
||
[ -e "$idx" ] || return 0
|
||
[ -n "$(find "$idx" -maxdepth 0 -mmin +10 2>/dev/null)" ] || return 1
|
||
rm -f "$idx" 2>/dev/null || return 1
|
||
ST_NOTE="$ST_NOTE; removed a stale git index lock left by an interrupted git process"
|
||
echo "BRAIN_SYNC: removed a stale git index lock (older than 10 minutes)" >&2
|
||
return 0
|
||
}
|
||
|
||
# Stage the paths listed in $1 with `git add -f` (forces past .gitignore=*).
|
||
# One batched add; when git refuses it, a per-path pass finds the paths it
|
||
# refuses. Rewrites $1 to the staged paths; sets ADD_FAILED and FIRST_ADD_ERR.
|
||
stage_paths() {
|
||
local paths_file="$1" p err
|
||
ADD_FAILED=0
|
||
FIRST_ADD_ERR=""
|
||
# Windows: compute_paths_to_stage's python print() emits CRLF; a trailing
|
||
# CR makes the pathspec match nothing.
|
||
tr -d '\r' < "$paths_file" > "$paths_file.lf"
|
||
if git --literal-pathspecs -C "$GSTACK_HOME" add -f --pathspec-from-file=- < "$paths_file.lf" >/dev/null 2>&1; then
|
||
mv -f "$paths_file.lf" "$paths_file"
|
||
return 0
|
||
fi
|
||
: > "$paths_file"
|
||
while IFS= read -r p; do
|
||
p="${p%$'\r'}"
|
||
[ -z "$p" ] && continue
|
||
if err=$(git --literal-pathspecs -C "$GSTACK_HOME" add -f -- "$p" 2>&1 >/dev/null); then
|
||
printf '%s\n' "$p" >> "$paths_file"
|
||
else
|
||
ADD_FAILED=$(( ADD_FAILED + 1 ))
|
||
[ -z "$FIRST_ADD_ERR" ] && FIRST_ADD_ERR=$(printf '%s' "$err" | head -1)
|
||
fi
|
||
done < "$paths_file.lf"
|
||
rm -f "$paths_file.lf"
|
||
}
|
||
|
||
# Plan the holds for one drain (one Python pass):
|
||
# $1 scanner hits ("<family>\t<path>"), $2 groups JSON from
|
||
# compute_paths_to_stage, $3 `git cat-file --batch` output for every group
|
||
# member's index entry, $4 the staged paths.
|
||
# A flagged path is held with the rest of its staged group (its dependents).
|
||
# A group that is not held must stage whole and pass its generation check, or
|
||
# all its staged members wait. Writes the held entries (JSON) to $5, the
|
||
# paths to unstage to $6 and the waiting paths to $7.
|
||
plan_holds() {
|
||
python3 - "$@" "$SELF_BIN" <<'PYEOF'
|
||
import json, sys
|
||
hits_path, groups_path, blobs_path, staged_path, held_out, unstage_out, wait_out, self_bin = sys.argv[1:9]
|
||
|
||
def lines(path):
|
||
with open(path, encoding="utf-8", errors="surrogateescape") as f:
|
||
return [l.rstrip("\r\n") for l in f if l.strip()]
|
||
|
||
hits = [tuple(l.split("\t", 1)) for l in lines(hits_path) if "\t" in l]
|
||
with open(groups_path) as f:
|
||
groups = json.load(f)
|
||
staged = set(lines(staged_path))
|
||
with open(blobs_path, "rb") as f:
|
||
data = f.read()
|
||
|
||
# Index blobs in batch order (None for an entry the index does not have).
|
||
blobs, pos = {}, 0
|
||
for g in groups:
|
||
for m in g["members"]:
|
||
nl = data.index(b"\n", pos)
|
||
header = data[pos:nl].split(b" ")
|
||
pos = nl + 1
|
||
if header[-1] == b"missing":
|
||
blobs[m] = None
|
||
continue
|
||
size = int(header[2])
|
||
blobs[m] = data[pos:pos + size]
|
||
pos += size + 1
|
||
|
||
def decisions_active(members):
|
||
log, snap = blobs.get(members[0]), blobs.get(members[1])
|
||
events = []
|
||
for line in (log or b"").decode("utf-8", "replace").split("\n"):
|
||
try:
|
||
e = json.loads(line)
|
||
except ValueError:
|
||
continue
|
||
if isinstance(e, dict):
|
||
events.append(e)
|
||
retired = {e.get("supersedes") for e in events if e.get("kind") in ("supersede", "redact")}
|
||
active = {e.get("id") for e in events if e.get("kind") == "decide" and e.get("id") not in retired}
|
||
if snap is None:
|
||
return not active
|
||
try:
|
||
rows = json.loads(snap.decode("utf-8", "replace"))
|
||
except ValueError:
|
||
return False
|
||
return isinstance(rows, list) and all(isinstance(r, dict) for r in rows) \
|
||
and {r.get("id") for r in rows} == active
|
||
|
||
CHECKS = {"decisions-active": decisions_active}
|
||
|
||
hit_paths = {p for _, p in hits}
|
||
held, unstage, waiting, held_groups = [], [], [], set()
|
||
for rule, path in hits:
|
||
group = next((g for g in groups if path in g["members"]), None)
|
||
deps = [m for m in (group["staged"] if group else []) if m != path and m not in hit_paths and m in staged]
|
||
if group:
|
||
held_groups.add(group["members"][0])
|
||
also = " and the files coupled with it" if deps else ""
|
||
held.append({
|
||
"path": path,
|
||
"rule": rule,
|
||
"dependents": deps,
|
||
"fixes": [
|
||
f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it",
|
||
f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})",
|
||
],
|
||
})
|
||
for m in [path] + deps:
|
||
if m not in unstage:
|
||
unstage.append(m)
|
||
for g in groups:
|
||
if g["members"][0] in held_groups:
|
||
continue
|
||
members_staged = [m for m in g["staged"] if m in staged]
|
||
if not members_staged:
|
||
continue
|
||
check = CHECKS.get(g.get("check"))
|
||
if len(members_staged) == len(g["staged"]) and check and check(g["members"]):
|
||
continue
|
||
for m in members_staged:
|
||
waiting.append(m)
|
||
if m not in unstage:
|
||
unstage.append(m)
|
||
|
||
with open(held_out, "w") as f:
|
||
json.dump(held, f)
|
||
for path, rows in ((unstage_out, unstage), (wait_out, waiting)):
|
||
with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as f:
|
||
f.writelines(r + "\n" for r in rows)
|
||
PYEOF
|
||
}
|
||
|
||
# Fail closed: unstage everything, keep the queue, status blocked.
|
||
block_drain() {
|
||
git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true
|
||
ST_DRAINABLE=0
|
||
write_status "blocked" "$1; nothing was committed and the queue is kept. Edit or skip the flagged files ($SELF_BIN --skip-file <path>), or clear the batch with $SELF_BIN --drop-queue --yes"
|
||
echo "BRAIN_SYNC: blocked: $1" >&2
|
||
}
|
||
|
||
# Secret scan and group check for the staged paths in $1 (groups JSON in $2):
|
||
# one scanner pass attributes findings to staged paths, plan_holds decides
|
||
# what is held or waits, those paths are unstaged and leave $1 (their records
|
||
# stay queued), then the whole remaining staged diff must re-scan clean.
|
||
# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain.
|
||
hold_flagged_paths() {
|
||
local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out
|
||
WAITING=0
|
||
: > "$scan.hits"
|
||
: > "$scan.blobs"
|
||
git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1
|
||
git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1
|
||
[ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; }
|
||
if [ "$rc" -eq 0 ] && [ -s "$groups_file.batch" ]; then
|
||
tr -d '\r' < "$groups_file.batch" | git -C "$GSTACK_HOME" cat-file --batch > "$scan.blobs" 2>/dev/null || rc=1
|
||
fi
|
||
[ "$rc" -eq 0 ] && { plan_holds "$scan.hits" "$groups_file" "$scan.blobs" "$paths_file" "$scan.held" "$scan.unstage" "$scan.wait" || rc=1; }
|
||
if [ "$rc" -ne 0 ]; then
|
||
block_drain "the secret scan could not tell which staged files it flagged"
|
||
return 1
|
||
fi
|
||
ST_HELD=$(cat "$scan.held")
|
||
WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true)
|
||
WAITING=${WAITING:-0}
|
||
if [ -s "$scan.unstage" ]; then
|
||
if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then
|
||
block_drain "git could not unstage the held files"
|
||
return 1
|
||
fi
|
||
{ grep -vxF -f "$scan.unstage" "$paths_file" || true; } > "$paths_file.kept"
|
||
mv -f "$paths_file.kept" "$paths_file"
|
||
fi
|
||
if ! git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null; then
|
||
block_drain "git could not read the staged diff for the re-scan"
|
||
return 1
|
||
fi
|
||
gate_out=$(secret_scan gate "$scan.diff") || gate_rc=$?
|
||
[ "$gate_rc" -eq 0 ] && return 0
|
||
if [ "$gate_rc" -eq 3 ]; then
|
||
block_drain "a re-scan after holding back the flagged files still matched ($gate_out)"
|
||
else
|
||
block_drain "the secret scan failed (exit $gate_rc)"
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Write to $1 the tracked files gone from disk that the sync may remove, one
|
||
# per line: allowlisted, not transcript pages (their consent guard owns those),
|
||
# and a coupled group only when every member is gone.
|
||
removal_candidates() {
|
||
git -C "$GSTACK_HOME" ls-files -d -z > "$1.gone" 2>/dev/null || { rm -f "$1.gone"; return 1; }
|
||
python3 - "$GSTACK_HOME" "$ALLOWLIST" "$COUPLED_GROUPS" "$1" <<'PYEOF'
|
||
import fnmatch, json, os, re, sys
|
||
home, allowlist_path, coupled, out = sys.argv[1:5]
|
||
try:
|
||
with open(allowlist_path) as f:
|
||
globs = [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")]
|
||
except FileNotFoundError:
|
||
globs = []
|
||
with open(out + ".gone", "rb") as f:
|
||
gone = {p for p in f.read().decode("utf-8", "surrogateescape").split("\0") if p}
|
||
os.remove(out + ".gone")
|
||
def removable(p):
|
||
if p.split("/")[0] == "transcripts" or not any(fnmatch.fnmatchcase(p, g) for g in globs):
|
||
return False
|
||
for spec in json.loads(coupled):
|
||
for member in spec["members"]:
|
||
m = re.fullmatch(re.escape(member).replace(r"\*", "([^/]+)"), p)
|
||
if m:
|
||
return all(not os.path.exists(os.path.join(home, x.replace("*", m.group(1), 1))) for x in spec["members"])
|
||
return True
|
||
with open(out, "w", newline="\n", encoding="utf-8", errors="surrogateescape") as f:
|
||
for p in sorted(gone):
|
||
if removable(p):
|
||
f.write(p + "\n")
|
||
PYEOF
|
||
}
|
||
|
||
# Commit the removal of the paths listed in $1 and push it, receipted like
|
||
# every push. Returns 0 pushed, 1 commit failed, 2 committed but not pushed
|
||
# (the run-start detector retries it).
|
||
publish_removals() {
|
||
local list="$1" n
|
||
n=$(grep -c . "$list" 2>/dev/null || true)
|
||
git --literal-pathspecs -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \
|
||
commit -q -m "sync: remove $n file(s) | $(date -u +%Y-%m-%dT%H:%M:%SZ)" --pathspec-from-file="$list" >/dev/null 2>&1 || return 1
|
||
guard_unpublished_range || return 2
|
||
GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$(remote_host)" curated-memory-git-push "artifacts_sync_mode!=off" \
|
||
bash -c 'GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME" || return 2
|
||
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
||
}
|
||
|
||
# The drain's removal step (artifacts_sync_removals=on). Records the outcome
|
||
# in ST_NOTE; sets REMOVALS_PUSHED when the removals reached the remote.
|
||
sync_removals() {
|
||
local list n rc=0
|
||
REMOVALS_PUSHED=0
|
||
list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || return 0
|
||
if ! removal_candidates "$list"; then
|
||
rm -f "$list" "$list.gone"
|
||
ST_NOTE="$ST_NOTE; could not list removed files; none published"
|
||
return 0
|
||
fi
|
||
n=$(grep -c . "$list" 2>/dev/null || true)
|
||
if [ "$n" -gt "$REMOVAL_VALVE" ]; then
|
||
ST_NOTE="$ST_NOTE; $n synced file(s) are gone from disk, more than $REMOVAL_VALVE at once, so none of the removals were published (review: $SELF_BIN --publish-removals; publish: $SELF_BIN --publish-removals --yes)"
|
||
echo "BRAIN_SYNC: holding $n removal(s), more than $REMOVAL_VALVE at once; review with $SELF_BIN --publish-removals" >&2
|
||
elif [ "$n" -gt 0 ]; then
|
||
publish_removals "$list" || rc=$?
|
||
case "$rc" in
|
||
0) REMOVALS_PUSHED=1; ST_NOTE="$ST_NOTE; published $n removal(s)" ;;
|
||
1) ST_NOTE="$ST_NOTE; committing $n removal(s) failed; the next sync retries" ;;
|
||
*) ST_NOTE="$ST_NOTE; $n removal(s) committed locally but not pushed" ;;
|
||
esac
|
||
fi
|
||
rm -f "$list"
|
||
}
|
||
|
||
subcmd_publish_removals() {
|
||
local confirm="${1:-}" list n rc=0
|
||
sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; }
|
||
gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; }
|
||
list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1
|
||
trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM
|
||
removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; }
|
||
n=$(grep -c . "$list" 2>/dev/null || true)
|
||
if [ "$n" -eq 0 ]; then
|
||
echo "no removals to publish"
|
||
exit 0
|
||
fi
|
||
if [ "$confirm" != "--yes" ]; then
|
||
echo "$n synced file(s) are gone from disk:"
|
||
sed 's/^/ /' "$list"
|
||
echo "publish their removal: $SELF_BIN --publish-removals --yes"
|
||
exit 0
|
||
fi
|
||
publish_removals "$list" || rc=$?
|
||
case "$rc" in
|
||
0) echo "published $n removal(s)" ;;
|
||
1) echo "BRAIN_SYNC: error: committing the removals failed" >&2; exit 1 ;;
|
||
*) echo "BRAIN_SYNC: $n removal(s) committed locally; the push failed and retries at skill start" >&2; exit 1 ;;
|
||
esac
|
||
}
|
||
|
||
subcmd_once() {
|
||
if ! sync_active; then
|
||
# Silent no-op when feature not initialized / disabled.
|
||
exit 0
|
||
fi
|
||
|
||
# One drain at a time, and never while another gstack writer runs git in
|
||
# $GSTACK_HOME (bin/gstack-brain-lock.sh). A held lock means skip, not wait:
|
||
# the next skill boundary catches up. Released on EVERY exit from here on,
|
||
# including the empty-queue fast path and an INT during the detector's
|
||
# network push; the mktemp block below re-traps with tempfile cleanup added.
|
||
gstack_brain_lock_acquire "$GSTACK_HOME" try || exit 0
|
||
trap 'gstack_brain_lock_release' EXIT INT TERM
|
||
|
||
# Convert any legacy single-file queue lines into spool records before the
|
||
# drain reads the spool (transition window for pre-spool writers).
|
||
migrate_legacy_queue
|
||
|
||
# Janitor: reap orphaned enqueue temp files. A writer killed between its
|
||
# tmp write and the atomic rename leaves `.tmp-*` behind forever — it never
|
||
# becomes a record and nothing else touches it. One hour is far beyond any
|
||
# live writer's write→rename window, so a fresh tmp (an in-flight enqueue)
|
||
# is never touched. Runs inside the run lock, so it can't race the drain.
|
||
find "$QUEUE_DIR" -maxdepth 1 -type f -name '.tmp-*' -mmin +60 -delete 2>/dev/null || true
|
||
|
||
local mode
|
||
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
|
||
|
||
# #2516: advance the brain worktree gbrain indexes to the artifacts repo's
|
||
# HEAD once a day — previously it only moved when setup-gbrain / sync-gbrain
|
||
# / brain-restore ran, so brains silently served stale code forever. Runs
|
||
# inside THIS run lock (never concurrent with the ingest steps below) and
|
||
# before they touch the worktree. Attempt-throttled: the stamp is written on
|
||
# ATTEMPT, so a persistently-failing advance warns once per 24h, not at
|
||
# every skill boundary. The advance itself refuses dirty or unmanaged
|
||
# worktrees and never force-removes (see gstack-gbrain-source-wireup).
|
||
if [ -e "${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" ]; then
|
||
local adv_stamp adv_now adv_last adv_age
|
||
adv_stamp="$GSTACK_HOME/.brain-worktree-last-advance"
|
||
adv_now=$(date +%s)
|
||
adv_last=$(cat "$adv_stamp" 2>/dev/null || echo 0)
|
||
case "$adv_last" in ''|*[!0-9]*) adv_last=0 ;; esac
|
||
adv_age=$(( adv_now - adv_last ))
|
||
if [ "$adv_age" -ge 86400 ]; then
|
||
echo "$adv_now" > "$adv_stamp" 2>/dev/null || true
|
||
if ! "$SCRIPT_DIR/gstack-gbrain-source-wireup" --advance-only 1>&2; then
|
||
echo "BRAIN_SYNC: warning: brain worktree advance failed — gbrain may be indexing stale code (run gstack-gbrain-source-wireup to repair)" >&2
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Removals go first, so a removal commit whose push fails is one more
|
||
# unpushed commit for the detector below.
|
||
REMOVALS_PUSHED=0
|
||
[ "$("$CONFIG_BIN" get artifacts_sync_removals 2>/dev/null || echo off)" = "on" ] && sync_removals
|
||
|
||
# #2549 unpushed-commit detector: a prior drain may have COMMITTED but
|
||
# failed to push (auth blip, offline). The data was never lost — it sits in
|
||
# a local commit — but nothing re-pushed it until NEW changes arrived.
|
||
# Retry the push up front, inside the lock. Receipted fail-closed like
|
||
# every other push; a receipt REFUSAL skips the retry without blocking the
|
||
# rest of the drain (local staging must not wedge on receipt problems).
|
||
# Guards: origin/<branch> may not exist yet (first sync, deleted remote).
|
||
#
|
||
# Throttled: the preamble runs --once at EVERY skill boundary, so an
|
||
# unthrottled retry would pay a full network push attempt per boundary in
|
||
# exactly the steady states this targets (offline, broken auth) — and a
|
||
# captive-portal push can block 30-75s against the header's "<1s when
|
||
# idle" promise. Attempts are recorded (success or fail) and retried at
|
||
# most every 10 minutes; the push itself never prompts for credentials and
|
||
# bounds stalled transfers via git's own low-speed limits (portable — stock
|
||
# macOS ships no `timeout` binary).
|
||
#
|
||
# Author-scoped — EXCLUSIVELY: `git push origin HEAD` publishes every
|
||
# unpushed commit, so the retry fires only when ALL unpushed commits are
|
||
# gstack-brain-sync's own. One interleaved user commit disables the
|
||
# auto-retry entirely (adversarial review: an existential check would
|
||
# silently auto-publish a user's manual ~/.gstack commit the moment a bot
|
||
# commit sat in front of it). User commits ride along when a REAL drain
|
||
# pushes, as before — the detector never publishes work it didn't create.
|
||
local det_branch det_unpushed det_total=0 det_now det_last
|
||
det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||
# Detached HEAD reads as the literal "HEAD" — origin/HEAD usually resolves,
|
||
# so without this exclusion the detector would retry a doomed push forever.
|
||
[ "$det_branch" = "HEAD" ] && det_branch=""
|
||
if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then
|
||
det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count --author="gstack-brain-sync" "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
|
||
det_total=$(git -C "$GSTACK_HOME" rev-list --count "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
|
||
case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac
|
||
case "$det_total" in ''|*[!0-9]*) det_total=0 ;; esac
|
||
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 ] && guard_unpublished_range; then
|
||
echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true
|
||
local det_host
|
||
det_host=$(remote_host)
|
||
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$det_host" curated-memory-git-push "artifacts_sync_mode!=off" \
|
||
bash -c 'GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
|
||
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
||
det_total=0
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Empty-queue fast path: this is the steady state at every skill boundary.
|
||
# Skipping compute/finalize here is safe — with zero spool records there is
|
||
# nothing to classify, retain, or drop, and a record created after this
|
||
# check simply waits for the next boundary. The legacy file is checked too:
|
||
# an OLD writer may have recreated it after the migration above (it gets
|
||
# migrated next run, but the depth is honest now) — and so is a leftover
|
||
# .migrating file: if its conversion failed above (e.g. python3 missing),
|
||
# records are still pending, so "idle" would be dishonest. (The detector
|
||
# above already ran: its whole point is re-pushing stranded commits when
|
||
# the queue is empty.) The lock-release trap installed at acquisition
|
||
# covers this exit.
|
||
if ! spool_has_records && [ ! -s "$QUEUE" ] && [ ! -s "$QUEUE.migrating" ]; then
|
||
# Stranded local commits that this run did not push keep the status at
|
||
# push_failed: an "idle" here used to erase the failure one boundary later,
|
||
# so a push that never recovered went silent.
|
||
if [ "$det_total" -gt 0 ]; then
|
||
write_status "push_failed" "$det_total local commit(s) are not pushed yet; the push retries at a skill start at most every 10 minutes (commits made outside gstack-brain-sync are never auto-pushed: push them with git -C $GSTACK_HOME push origin HEAD)"
|
||
exit 0
|
||
fi
|
||
if [ "$REMOVALS_PUSHED" = 1 ]; then write_status "ok" "queue empty"; else write_status "idle" "queue empty"; fi
|
||
exit 0
|
||
fi
|
||
|
||
local paths_file class_file snapshot_file groups_file
|
||
paths_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-paths.XXXXXX") || { write_status "error" "mktemp failed"; exit 1; }
|
||
class_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-class.XXXXXX") || { rm -f "$paths_file"; write_status "error" "mktemp failed"; exit 1; }
|
||
snapshot_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-snapshot.XXXXXX") || { rm -f "$paths_file" "$class_file"; write_status "error" "mktemp failed"; exit 1; }
|
||
groups_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-groups.XXXXXX") || { rm -f "$paths_file" "$class_file" "$snapshot_file"; write_status "error" "mktemp failed"; exit 1; }
|
||
# Single trap covers all: lock release AND tempfile cleanup (the siblings
|
||
# are the staging and hold steps' scratch files).
|
||
trap 'rm -f "$paths_file" "$paths_file".* "$class_file" "$snapshot_file" "$groups_file" "$groups_file".* 2>/dev/null; gstack_brain_lock_release' EXIT INT TERM
|
||
|
||
# Fail-safe (G1): a classifier that dies mid-run (ENOSPC/OOM/SIGKILL, or a
|
||
# shape the defensive filters don't cover) may have already written the
|
||
# 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" "$groups_file" > "$paths_file"; then
|
||
echo "BRAIN_SYNC: warning: queue classification failed — no records consumed; next run re-drains" >&2
|
||
write_status "error" "classification failed; queue preserved (next run retries)"
|
||
exit 0
|
||
fi
|
||
if [ ! -s "$paths_file" ]; then
|
||
# Nothing stageable. Finalize the snapshot (retained entries survive;
|
||
# classified drops removed; records created after the snapshot untouched).
|
||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||
local summary
|
||
summary=$(queue_summary "$class_file")
|
||
write_status "idle" "no stageable changes${summary:+ ($summary)}"
|
||
exit 0
|
||
fi
|
||
local n_stageable
|
||
n_stageable=$(grep -c . "$paths_file" 2>/dev/null || true)
|
||
|
||
# Every add, reset and commit below needs the git index. The drain lock
|
||
# proves no other gstack writer is running git here, so an index lock older
|
||
# than 10 minutes is abandoned and is removed; a younger one is a live git
|
||
# process (the user's own), which the drain reports and waits out.
|
||
if ! clear_stale_index_lock; then
|
||
ST_DRAINABLE=$n_stageable
|
||
write_status "error" "a git index lock ($GSTACK_HOME/.git/index.lock) younger than 10 minutes shows another git process running in the artifacts repo; queue kept, the next sync retries"
|
||
echo "BRAIN_SYNC: error: git index lock present; another git process is running in $GSTACK_HOME" >&2
|
||
exit 0
|
||
fi
|
||
|
||
# Stage explicit paths only. A path git refuses to add leaves $paths_file,
|
||
# so its record stays queued and the commit count stays honest.
|
||
stage_paths "$paths_file"
|
||
if [ ! -s "$paths_file" ]; then
|
||
ST_DRAINABLE=$n_stageable
|
||
write_status "error" "git add failed for all $ADD_FAILED path(s): ${FIRST_ADD_ERR:-unknown error}; queue preserved (next run retries)"
|
||
echo "BRAIN_SYNC: error: git add failed for all $ADD_FAILED path(s): ${FIRST_ADD_ERR:-unknown error}" >&2
|
||
exit 0
|
||
fi
|
||
if [ "$ADD_FAILED" -gt 0 ]; then
|
||
ST_NOTE="$ST_NOTE; git add failed for $ADD_FAILED path(s), still queued (${FIRST_ADD_ERR:-unknown error})"
|
||
echo "BRAIN_SYNC: warning: git add failed for $ADD_FAILED path(s): ${FIRST_ADD_ERR:-unknown error}" >&2
|
||
fi
|
||
|
||
# Secret scan: flagged files (with their coupled groups) are held back and
|
||
# stay queued; groups whose staged generation is inconsistent wait. Nothing
|
||
# matching a scanner pattern is ever committed: the remaining staged diff
|
||
# must re-scan clean, or the drain unstages everything (blocked).
|
||
hold_flagged_paths "$paths_file" "$groups_file" || exit 0
|
||
ST_DRAINABLE=$(( ADD_FAILED + WAITING ))
|
||
[ "$WAITING" -gt 0 ] && ST_NOTE="$ST_NOTE; $WAITING file(s) of a coupled group wait for a consistent generation (still queued)"
|
||
local held_n ok_code="ok"
|
||
held_n=$(printf '%s' "$ST_HELD" | grep -o '"rule"' | wc -l | tr -d ' ')
|
||
if [ "$held_n" -gt 0 ]; then
|
||
ok_code="held"
|
||
ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes"
|
||
echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2
|
||
fi
|
||
|
||
# A verified empty staged diff is the only "nothing to commit"; anything
|
||
# else that stops the commit keeps the queue (status error).
|
||
local cached_rc=0
|
||
git -C "$GSTACK_HOME" diff --cached --quiet >/dev/null 2>&1 || cached_rc=$?
|
||
if [ "$cached_rc" -eq 0 ]; then
|
||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||
if [ "$ok_code" = "held" ]; then
|
||
write_status "held" "nothing else to sync"
|
||
else
|
||
write_status "idle" "queue drained but no new changes to commit"
|
||
fi
|
||
exit 0
|
||
fi
|
||
if [ "$cached_rc" -ne 1 ]; then
|
||
git -C "$GSTACK_HOME" reset -q >/dev/null 2>&1 || true
|
||
ST_DRAINABLE=$n_stageable
|
||
write_status "error" "git could not read the staged changes (exit $cached_rc); queue kept, the next sync retries"
|
||
exit 0
|
||
fi
|
||
|
||
# Egress receipt for the push, written BEFORE the commit consumes the
|
||
# queue (amendment C7 ordering): a refused receipt exits HERE, before any
|
||
# queue mutation or local commit, so the queue stays intact and the next
|
||
# run retries the whole drain. Content-free: git owns the bytes
|
||
# (sha256:null). Fail-closed.
|
||
local push_host receipt_err
|
||
push_host=$(remote_host)
|
||
if ! receipt_err=$(GSTACK_HOME="$GSTACK_HOME" "$SCRIPT_DIR/gstack-egress-receipt" write \
|
||
--sink brain-sync --host "$push_host" --class curated-memory-git-push \
|
||
--no-payload --consent "artifacts_sync_mode!=off" 2>&1 >/dev/null); then
|
||
write_status "push_failed" "EGRESS_RECEIPT_FAILED: receipt not writable; push refused (queue preserved)"
|
||
_gstack_egress_refusal "brain-sync push" "$(printf '%s' "$receipt_err" | head -c 300)"
|
||
exit 1
|
||
fi
|
||
|
||
# Commit with template message. A failed commit (a rejecting hook, a git
|
||
# error) unstages, keeps the queue and says why.
|
||
local n ts commit_err
|
||
n=$(wc -l < "$paths_file" | tr -d ' ')
|
||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||
local msg="sync: $n file(s) | $ts"
|
||
if ! commit_err=$(git -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \
|
||
commit -q -m "$msg" 2>&1 >/dev/null); then
|
||
git -C "$GSTACK_HOME" reset -q >/dev/null 2>&1 || true
|
||
ST_DRAINABLE=$(( ST_DRAINABLE + n ))
|
||
write_status "error" "git commit failed: $(printf '%s' "$commit_err" | head -1 | head -c 200); queue kept, the next sync retries (check $GSTACK_HOME/.git/hooks)"
|
||
echo "BRAIN_SYNC: error: git commit failed" >&2
|
||
exit 0
|
||
fi
|
||
|
||
# Nothing outside the transcript consent is pushed; a refused rewrite keeps the commit local.
|
||
guard_unpublished_range || { write_status "push_failed" "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) || {
|
||
# Check if this is an auth error first — no point retrying.
|
||
if echo "$push_err" | grep -qiE "auth|permission|403|401|forbidden"; then
|
||
local hint
|
||
hint=$(remote_auth_hint)
|
||
write_status "push_failed" "push failed: auth error; commit retained locally, will retry next run. fix: $hint"
|
||
echo "BRAIN_SYNC: push failed: auth. fix: $hint" >&2
|
||
# Drained records leave the spool — they live in the local commit, which
|
||
# the run-start detector re-pushes next time (#2549). Retained +
|
||
# post-snapshot records survive the finalize.
|
||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||
exit 0
|
||
fi
|
||
|
||
# Try a fetch-and-merge + retry. The fetch and the retry push are their
|
||
# own attempted-egress ops, each receipted fail-closed (a refusal falls
|
||
# through to the push_failed path below).
|
||
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-fetch "artifacts_sync_mode!=off" \
|
||
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 && 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"
|
||
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
||
write_status "$ok_code" "pushed $n file(s) after rebase"
|
||
exit 0
|
||
fi
|
||
fi
|
||
fi
|
||
# Commit exists locally; the run-start detector re-pushes it next time.
|
||
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
|
||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||
exit 0
|
||
}
|
||
|
||
# Success: drained records leave the spool (retained + post-snapshot survive).
|
||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
||
write_status "$ok_code" "pushed $n file(s)"
|
||
exit 0
|
||
}
|
||
|
||
subcmd_status() {
|
||
if [ -f "$STATUS_FILE" ]; then
|
||
cat "$STATUS_FILE"
|
||
else
|
||
echo '{"status":"unknown","message":"no status file yet"}'
|
||
fi
|
||
# Supplemental info (not in status file). Depth = spool record files plus
|
||
# any not-yet-migrated legacy queue lines (transition window), including a
|
||
# crash-leftover .migrating file — its records are still pending too.
|
||
local queue_depth spool_depth legacy_depth
|
||
# find, not `ls "$QUEUE_DIR"/*.json`: a deep spool overflows the argv limit
|
||
# and the glob form then reported 0.
|
||
spool_depth=$(find "$QUEUE_DIR" -maxdepth 1 -type f -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
||
legacy_depth=0
|
||
[ -f "$QUEUE" ] && legacy_depth=$(wc -l < "$QUEUE" | tr -d ' ')
|
||
[ -f "$QUEUE.migrating" ] && legacy_depth=$(( legacy_depth + $(wc -l < "$QUEUE.migrating" | tr -d ' ') ))
|
||
queue_depth=$(( spool_depth + legacy_depth ))
|
||
local last_push="never"
|
||
[ -f "$LAST_PUSH_FILE" ] && last_push=$(cat "$LAST_PUSH_FILE" 2>/dev/null || echo never)
|
||
local mode
|
||
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
|
||
printf '{"queue_depth":%s,"last_push":"%s","mode":"%s"}\n' "$queue_depth" "$last_push" "$mode"
|
||
}
|
||
|
||
subcmd_skip_file() {
|
||
local path="${1:-}"
|
||
if [ -z "$path" ]; then
|
||
echo "Usage: $SELF_BIN --skip-file <path>" >&2
|
||
exit 1
|
||
fi
|
||
mkdir -p "$GSTACK_HOME"
|
||
# Avoid duplicate entries.
|
||
if [ -f "$SKIP_FILE" ] && grep -Fxq "$path" "$SKIP_FILE"; then
|
||
echo "already in skip list: $path"
|
||
exit 0
|
||
fi
|
||
echo "$path" >> "$SKIP_FILE"
|
||
echo "added to skip list: $path"
|
||
echo "(permanent: it no longer syncs, nor do the files of any coupled group it belongs to; queued records drop on the next --once; undo with $SELF_BIN --unskip-file $path)"
|
||
}
|
||
|
||
subcmd_unskip_file() {
|
||
local path="${1:-}"
|
||
if [ -z "$path" ]; then
|
||
echo "Usage: $SELF_BIN --unskip-file <path>" >&2
|
||
exit 1
|
||
fi
|
||
if [ ! -f "$SKIP_FILE" ] || ! grep -Fxq "$path" "$SKIP_FILE"; then
|
||
echo "not in skip list: $path"
|
||
exit 0
|
||
fi
|
||
local kept
|
||
kept=$(mktemp "$SKIP_FILE.XXXXXX") || { echo "could not update $SKIP_FILE" >&2; exit 1; }
|
||
{ grep -Fxv "$path" "$SKIP_FILE" || true; } > "$kept"
|
||
mv -f "$kept" "$SKIP_FILE"
|
||
echo "removed from skip list: $path"
|
||
if [ -f "$GSTACK_HOME/$path" ]; then
|
||
"$SCRIPT_DIR/gstack-brain-enqueue" "$path"
|
||
echo "(queued; the next sync publishes it, together with any coupled group it belongs to)"
|
||
fi
|
||
}
|
||
|
||
# Skill start's attention lines: one per row of the sync status table in this
|
||
# script's header that applies. Every value read from the status file is
|
||
# clamped (status code to the table's closed set, counts to digits,
|
||
# timestamps to their shape); the message, held paths and scanner output never
|
||
# reach this output, only fixed text and this script's absolute path.
|
||
subcmd_attention() {
|
||
sync_active || exit 0
|
||
local line="" prefix code="" held="" drainable="" last_drain="" last_push="" bin="$SELF_BIN"
|
||
if [ -f "$STATUS_FILE" ]; then
|
||
line=$(head -c 65536 "$STATUS_FILE" 2>/dev/null | tr -d '\r\n')
|
||
# The counts and timestamps precede every free-text field.
|
||
prefix=${line%%, \"held\": *}
|
||
code=$(printf '%s' "$prefix" | sed -n 's/^{"status": *"\([a-z_]*\)".*/\1/p')
|
||
held=$(printf '%s' "$prefix" | sed -n 's/.*"held_count": *\([0-9][0-9]*\).*/\1/p')
|
||
drainable=$(printf '%s' "$prefix" | sed -n 's/.*"drainable": *\([0-9][0-9]*\).*/\1/p')
|
||
last_drain=$(printf '%s' "$prefix" | sed -n 's/.*"last_drain_at": *"\([^"]*\)".*/\1/p')
|
||
last_push=$(printf '%s' "$prefix" | sed -n 's/.*"last_push_at": *"\([^"]*\)".*/\1/p')
|
||
case "$code" in ok|idle|held|blocked|push_failed|error) ;; *) code="unknown" ;; esac
|
||
fi
|
||
case "$held" in ''|*[!0-9]*) held=0 ;; esac
|
||
case "$drainable" in ''|*[!0-9]*) drainable=0 ;; esac
|
||
local iso='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'
|
||
case "$last_drain" in $iso) ;; *) last_drain="" ;; esac
|
||
case "$last_push" in $iso) ;; *) last_push="" ;; esac
|
||
|
||
case "$code" in
|
||
held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. The secret scan is holding back $held file(s); everything else still syncs. See which files and how to fix them: $bin --status" ;;
|
||
blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: $bin --status" ;;
|
||
push_failed) echo "ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: $bin --status" ;;
|
||
error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;;
|
||
unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;;
|
||
esac
|
||
|
||
local now cutoff oldest="" oldest_epoch
|
||
now=$(date +%s 2>/dev/null || echo "")
|
||
case "$now" in ''|*[!0-9]*) return 0 ;; esac
|
||
cutoff=$(date -u -d "@$(( now - 86400 ))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$(( now - 86400 ))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
|
||
[ -n "$cutoff" ] || return 0
|
||
oldest=$(find "$QUEUE_DIR" -maxdepth 1 -type f -name '*.json' 2>/dev/null | sed 's#.*/##' | sort | head -1)
|
||
if [ "$drainable" -gt 0 ]; then
|
||
oldest_epoch=${oldest%%-*}
|
||
case "$oldest_epoch" in ''|*[!0-9]*) oldest_epoch=$now ;; esac
|
||
if { [ -n "$last_push" ] && [[ "$last_push" < "$cutoff" ]]; } \
|
||
|| { [ -z "$last_push" ] && [ "$oldest_epoch" -lt $(( now - 86400 )) ]; }; then
|
||
echo "ARTIFACTS_SYNC: attention: stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: $bin --status"
|
||
fi
|
||
fi
|
||
if [ -n "$oldest" ] || [ -s "$QUEUE" ] || [ -s "$QUEUE.migrating" ]; then
|
||
if [ -z "$last_drain" ] || [[ "$last_drain" < "$cutoff" ]]; then
|
||
echo "ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: $bin --status"
|
||
fi
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
subcmd_drop_queue() {
|
||
local force="${1:-}"
|
||
if [ "$force" != "--yes" ]; then
|
||
echo "Refusing: --drop-queue discards pending syncs. Pass --yes to confirm." >&2
|
||
exit 1
|
||
fi
|
||
# Remove spool record files, then truncate any legacy queue remnant —
|
||
# including a crash-leftover .migrating file, whose records would otherwise
|
||
# resurrect on the next drain via migrate_legacy_queue after the user
|
||
# explicitly discarded the queue.
|
||
local n=0 f
|
||
for f in "$QUEUE_DIR"/*.json; do
|
||
[ -e "$f" ] || continue
|
||
rm -f "$f" 2>/dev/null && n=$(( n + 1 ))
|
||
done
|
||
if [ -f "$QUEUE" ]; then
|
||
local legacy_n
|
||
legacy_n=$(wc -l < "$QUEUE" | tr -d ' ')
|
||
n=$(( n + legacy_n ))
|
||
: > "$QUEUE"
|
||
fi
|
||
if [ -f "$QUEUE.migrating" ]; then
|
||
local mig_n
|
||
mig_n=$(wc -l < "$QUEUE.migrating" | tr -d ' ')
|
||
n=$(( n + mig_n ))
|
||
rm -f "$QUEUE.migrating" 2>/dev/null || true
|
||
fi
|
||
if [ "$n" -eq 0 ]; then
|
||
echo "queue already empty"
|
||
exit 0
|
||
fi
|
||
echo "dropped $n queue entries"
|
||
}
|
||
|
||
subcmd_discover_new() {
|
||
if ! sync_active; then
|
||
exit 0
|
||
fi
|
||
# Walk allowlist globs; enqueue any file where mtime+size differs from cursor.
|
||
python3 - "$GSTACK_HOME" "$ALLOWLIST" "$DISCOVER_CURSOR" <<'PYEOF' 2>/dev/null || true
|
||
import sys, os, json, fnmatch, time
|
||
from datetime import datetime, timezone
|
||
|
||
gstack_home, allowlist_path, cursor_path = sys.argv[1:4]
|
||
spool_dir = os.path.join(gstack_home, ".brain-queue.d")
|
||
skip_path = os.path.join(gstack_home, ".brain-skip.txt")
|
||
|
||
def load_lines(path):
|
||
try:
|
||
with open(path) as f:
|
||
return [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")]
|
||
except FileNotFoundError:
|
||
return []
|
||
|
||
def load_cursor(path):
|
||
try:
|
||
with open(path) as f:
|
||
return json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
return {}
|
||
|
||
def save_cursor(path, data):
|
||
try:
|
||
with open(path, "w") as f:
|
||
json.dump(data, f)
|
||
except OSError:
|
||
pass
|
||
|
||
allowlist = load_lines(allowlist_path)
|
||
# Normalize skip entries to the same POSIX form as `rel` below, so a
|
||
# backslash entry in .brain-skip.txt still matches a normalized path on Windows.
|
||
skip = {s.replace(os.sep, "/") for s in load_lines(skip_path)}
|
||
cursor = load_cursor(cursor_path)
|
||
new_cursor = dict(cursor)
|
||
to_enqueue = []
|
||
|
||
# Walk all files under gstack_home, match against allowlist.
|
||
for root, dirs, files in os.walk(gstack_home):
|
||
# Skip .git and .brain-* state files.
|
||
if ".git" in root.split(os.sep):
|
||
continue
|
||
for name in files:
|
||
full = os.path.join(root, name)
|
||
# Repo paths are POSIX-relative. os.path.relpath yields backslash
|
||
# separators on Windows, which never match the forward-slash allowlist
|
||
# globs (e.g. "projects/*/learnings.jsonl"), so discovery silently
|
||
# enqueued nothing under projects/ on Windows. Normalize to "/".
|
||
rel = os.path.relpath(full, gstack_home).replace(os.sep, "/")
|
||
if rel.startswith(".brain-"):
|
||
continue
|
||
if not any(fnmatch.fnmatchcase(rel, pat) for pat in allowlist):
|
||
continue
|
||
if rel in skip:
|
||
continue
|
||
try:
|
||
st = os.stat(full)
|
||
key = f"{int(st.st_mtime)}:{st.st_size}"
|
||
except OSError:
|
||
continue
|
||
if cursor.get(rel) != key:
|
||
to_enqueue.append((rel, key))
|
||
|
||
# Write spool records directly. The previous implementation shelled out to
|
||
# gstack-brain-enqueue once per file, but Windows Python cannot exec a
|
||
# bash-shebang script (the spawn fails with a fork error), so discovery
|
||
# enqueued nothing on Windows even after the path-match fix above.
|
||
# Writing the record here is platform-agnostic; the drain step
|
||
# (compute_paths_to_stage) still re-applies the skip-list + privacy filters.
|
||
if to_enqueue:
|
||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
written = []
|
||
try:
|
||
# One spool FILE per record (tmp write + atomic os.replace), matching
|
||
# gstack-brain-enqueue's maildir contract: writers and the drain never
|
||
# share an inode, so a parallel writer or drain can't race this.
|
||
# Compact separators match the shim's JSON shape.
|
||
os.makedirs(spool_dir, exist_ok=True)
|
||
for i, (rel, key) in enumerate(to_enqueue):
|
||
rec = json.dumps({"file": rel, "ts": ts}, separators=(",", ":"))
|
||
tmp = os.path.join(spool_dir, f".tmp-{os.getpid()}-d{i}")
|
||
with open(tmp, "w") as f:
|
||
f.write(rec + "\n")
|
||
os.replace(tmp, os.path.join(spool_dir, f"{int(time.time())}-{os.getpid()}-d{i}.json"))
|
||
written.append((rel, key))
|
||
except OSError:
|
||
# Spool write failed (disk full, AV file lock). Leave the cursor
|
||
# unadvanced for unwritten records so they are retried on the next
|
||
# discover instead of being silently recorded as synced (which loses
|
||
# the change until the file next changes).
|
||
pass
|
||
# Advance the cursor only for records actually written.
|
||
for rel, key in written:
|
||
new_cursor[rel] = key
|
||
|
||
save_cursor(cursor_path, new_cursor)
|
||
PYEOF
|
||
}
|
||
|
||
# -------- dispatch --------
|
||
case "${1:-}" in
|
||
--once|"") subcmd_once ;;
|
||
--status) subcmd_status ;;
|
||
--skip-file) shift; subcmd_skip_file "${1:-}" ;;
|
||
--unskip-file) shift; subcmd_unskip_file "${1:-}" ;;
|
||
--drop-queue) shift; subcmd_drop_queue "${1:-}" ;;
|
||
--discover-new) subcmd_discover_new ;;
|
||
--attention) subcmd_attention ;;
|
||
--publish-removals) shift; subcmd_publish_removals "${1:-}" ;;
|
||
--help|-h)
|
||
awk 'NR > 1 && /^# Heredoc delivery guard/ { exit } NR > 1' "$0" | sed 's/^# \{0,1\}//'
|
||
;;
|
||
*)
|
||
echo "Unknown subcommand: $1" >&2
|
||
echo "Run: gstack-brain-sync --help" >&2
|
||
exit 1
|
||
;;
|
||
esac
|