From 45fb5e485044412f1d4ce7dfd3d8a1dd59fce6fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 17:04:40 +0000 Subject: [PATCH] fix(skills): make gstack slash skills visible in Cursor Cursor indexes one-level skill dirs (.cursor/skills//SKILL.md), so the nested .claude/skills/gstack/ pack was invisible as /plan-ceo-review. Flatten each skill into .cursor/skills and ~/.cursor/skills, and vendor gstack bin/setup so the skill-start preamble is runnable. Co-authored-by: Prax Lannister --- .agents/skills/gstack/ETHOS.md | 169 ++ .agents/skills/gstack/NOTICE.md | 6 + .agents/skills/gstack/VERSION | 1 + .agents/skills/gstack/bin/dev-setup | 134 + .agents/skills/gstack/bin/dev-teardown | 63 + .agents/skills/gstack/bin/gstack-analytics | 191 ++ .../skills/gstack/bin/gstack-artifacts-init | 490 ++++ .../skills/gstack/bin/gstack-artifacts-url | 119 + .agents/skills/gstack/bin/gstack-brain-cache | 1045 +++++++ .../gstack/bin/gstack-brain-context-load.ts | 482 ++++ .../skills/gstack/bin/gstack-brain-enqueue | 64 + .../skills/gstack/bin/gstack-brain-restore | 246 ++ .agents/skills/gstack/bin/gstack-brain-sync | 963 +++++++ .../skills/gstack/bin/gstack-brain-uninstall | 165 ++ .../skills/gstack/bin/gstack-builder-profile | 13 + .../gstack/bin/gstack-code-intelligence | 284 ++ .agents/skills/gstack/bin/gstack-codex-probe | 211 ++ .../gstack/bin/gstack-codex-session-import | 226 ++ .../gstack/bin/gstack-community-dashboard | 141 + .agents/skills/gstack/bin/gstack-config | 561 ++++ .agents/skills/gstack/bin/gstack-context-bill | 8 + .agents/skills/gstack/bin/gstack-decision-log | 116 + .../skills/gstack/bin/gstack-decision-search | 108 + .agents/skills/gstack/bin/gstack-detach | 167 ++ .../gstack/bin/gstack-developer-profile | 525 ++++ .agents/skills/gstack/bin/gstack-diff-scope | 203 ++ .../skills/gstack/bin/gstack-distill-apply | 181 ++ .../gstack/bin/gstack-distill-free-text | 287 ++ .agents/skills/gstack/bin/gstack-egress | 218 ++ .../skills/gstack/bin/gstack-egress-lib.sh | 118 + .../skills/gstack/bin/gstack-egress-receipt | 84 + .agents/skills/gstack/bin/gstack-evidence | 543 ++++ .agents/skills/gstack/bin/gstack-extension | 65 + .../gstack/bin/gstack-first-task-detect | 105 + .../skills/gstack/bin/gstack-gbrain-detect | 308 ++ .../skills/gstack/bin/gstack-gbrain-install | 318 +++ .../skills/gstack/bin/gstack-gbrain-lib.sh | 115 + .../gstack/bin/gstack-gbrain-mcp-verify | 192 ++ .../gstack/bin/gstack-gbrain-repo-policy | 285 ++ .../gstack/bin/gstack-gbrain-source-wireup | 431 +++ .../bin/gstack-gbrain-supabase-provision | 29 + .../gstack/bin/gstack-gbrain-supabase-verify | 126 + .../skills/gstack/bin/gstack-gbrain-sync.ts | 1774 ++++++++++++ .../gstack/bin/gstack-global-discover.ts | 609 ++++ .../skills/gstack/bin/gstack-ios-qa-daemon | 39 + .agents/skills/gstack/bin/gstack-ios-qa-mint | 28 + .agents/skills/gstack/bin/gstack-ios-qa-regen | 154 + .agents/skills/gstack/bin/gstack-issue-guard | 98 + .agents/skills/gstack/bin/gstack-jsonl-merge | 105 + .../skills/gstack/bin/gstack-learnings-log | 91 + .../skills/gstack/bin/gstack-learnings-search | 162 ++ .../skills/gstack/bin/gstack-memory-ingest.ts | 2447 ++++++++++++++++ .../skills/gstack/bin/gstack-model-benchmark | 207 ++ .agents/skills/gstack/bin/gstack-next-version | 767 +++++ .agents/skills/gstack/bin/gstack-patch-names | 34 + .agents/skills/gstack/bin/gstack-paths | 86 + .../gstack/bin/gstack-pr-title-rewrite.sh | 60 + .agents/skills/gstack/bin/gstack-question-log | 254 ++ .../gstack/bin/gstack-question-preference | 322 +++ .agents/skills/gstack/bin/gstack-redact | 303 ++ .../skills/gstack/bin/gstack-redact-prepush | 485 ++++ .agents/skills/gstack/bin/gstack-relink | 120 + .agents/skills/gstack/bin/gstack-repo-mode | 108 + .../skills/gstack/bin/gstack-retro-metrics | 360 +++ .agents/skills/gstack/bin/gstack-review-log | 62 + .agents/skills/gstack/bin/gstack-review-read | 25 + .../gstack/bin/gstack-security-dashboard | 166 ++ .agents/skills/gstack/bin/gstack-session-kind | 53 + .../skills/gstack/bin/gstack-session-update | 210 ++ .../skills/gstack/bin/gstack-settings-hook | 851 ++++++ .agents/skills/gstack/bin/gstack-skill-end | 60 + .agents/skills/gstack/bin/gstack-skill-start | 506 ++++ .agents/skills/gstack/bin/gstack-slug | 278 ++ .../skills/gstack/bin/gstack-specialist-stats | 65 + .agents/skills/gstack/bin/gstack-taste-update | 293 ++ .agents/skills/gstack/bin/gstack-team-init | 216 ++ .../skills/gstack/bin/gstack-telemetry-log | 275 ++ .../skills/gstack/bin/gstack-telemetry-sync | 182 ++ .agents/skills/gstack/bin/gstack-timeline-log | 40 + .../skills/gstack/bin/gstack-timeline-read | 96 + .agents/skills/gstack/bin/gstack-uninstall | 402 +++ .agents/skills/gstack/bin/gstack-update-check | 288 ++ .agents/skills/gstack/bin/gstack-verify-gate | 214 ++ .agents/skills/gstack/bin/gstack-version-bump | 528 ++++ .agents/skills/gstack/bin/gstack-wtree | 56 + .agents/skills/gstack/setup | 2535 +++++++++++++++++ .claude/skills/gstack/ETHOS.md | 169 ++ .claude/skills/gstack/NOTICE.md | 6 + .claude/skills/gstack/VERSION | 1 + .claude/skills/gstack/bin/dev-setup | 134 + .claude/skills/gstack/bin/dev-teardown | 63 + .claude/skills/gstack/bin/gstack-analytics | 191 ++ .../skills/gstack/bin/gstack-artifacts-init | 490 ++++ .../skills/gstack/bin/gstack-artifacts-url | 119 + .claude/skills/gstack/bin/gstack-brain-cache | 1045 +++++++ .../gstack/bin/gstack-brain-context-load.ts | 482 ++++ .../skills/gstack/bin/gstack-brain-enqueue | 64 + .../skills/gstack/bin/gstack-brain-restore | 246 ++ .claude/skills/gstack/bin/gstack-brain-sync | 963 +++++++ .../skills/gstack/bin/gstack-brain-uninstall | 165 ++ .../skills/gstack/bin/gstack-builder-profile | 13 + .../gstack/bin/gstack-code-intelligence | 284 ++ .claude/skills/gstack/bin/gstack-codex-probe | 211 ++ .../gstack/bin/gstack-codex-session-import | 226 ++ .../gstack/bin/gstack-community-dashboard | 141 + .claude/skills/gstack/bin/gstack-config | 561 ++++ .claude/skills/gstack/bin/gstack-context-bill | 8 + .claude/skills/gstack/bin/gstack-decision-log | 116 + .../skills/gstack/bin/gstack-decision-search | 108 + .claude/skills/gstack/bin/gstack-detach | 167 ++ .../gstack/bin/gstack-developer-profile | 525 ++++ .claude/skills/gstack/bin/gstack-diff-scope | 203 ++ .../skills/gstack/bin/gstack-distill-apply | 181 ++ .../gstack/bin/gstack-distill-free-text | 287 ++ .claude/skills/gstack/bin/gstack-egress | 218 ++ .../skills/gstack/bin/gstack-egress-lib.sh | 118 + .../skills/gstack/bin/gstack-egress-receipt | 84 + .claude/skills/gstack/bin/gstack-evidence | 543 ++++ .claude/skills/gstack/bin/gstack-extension | 65 + .../gstack/bin/gstack-first-task-detect | 105 + .../skills/gstack/bin/gstack-gbrain-detect | 308 ++ .../skills/gstack/bin/gstack-gbrain-install | 318 +++ .../skills/gstack/bin/gstack-gbrain-lib.sh | 115 + .../gstack/bin/gstack-gbrain-mcp-verify | 192 ++ .../gstack/bin/gstack-gbrain-repo-policy | 285 ++ .../gstack/bin/gstack-gbrain-source-wireup | 431 +++ .../bin/gstack-gbrain-supabase-provision | 29 + .../gstack/bin/gstack-gbrain-supabase-verify | 126 + .../skills/gstack/bin/gstack-gbrain-sync.ts | 1774 ++++++++++++ .../gstack/bin/gstack-global-discover.ts | 609 ++++ .../skills/gstack/bin/gstack-ios-qa-daemon | 39 + .claude/skills/gstack/bin/gstack-ios-qa-mint | 28 + .claude/skills/gstack/bin/gstack-ios-qa-regen | 154 + .claude/skills/gstack/bin/gstack-issue-guard | 98 + .claude/skills/gstack/bin/gstack-jsonl-merge | 105 + .../skills/gstack/bin/gstack-learnings-log | 91 + .../skills/gstack/bin/gstack-learnings-search | 162 ++ .../skills/gstack/bin/gstack-memory-ingest.ts | 2447 ++++++++++++++++ .../skills/gstack/bin/gstack-model-benchmark | 207 ++ .claude/skills/gstack/bin/gstack-next-version | 767 +++++ .claude/skills/gstack/bin/gstack-patch-names | 34 + .claude/skills/gstack/bin/gstack-paths | 86 + .../gstack/bin/gstack-pr-title-rewrite.sh | 60 + .claude/skills/gstack/bin/gstack-question-log | 254 ++ .../gstack/bin/gstack-question-preference | 322 +++ .claude/skills/gstack/bin/gstack-redact | 303 ++ .../skills/gstack/bin/gstack-redact-prepush | 485 ++++ .claude/skills/gstack/bin/gstack-relink | 120 + .claude/skills/gstack/bin/gstack-repo-mode | 108 + .../skills/gstack/bin/gstack-retro-metrics | 360 +++ .claude/skills/gstack/bin/gstack-review-log | 62 + .claude/skills/gstack/bin/gstack-review-read | 25 + .../gstack/bin/gstack-security-dashboard | 166 ++ .claude/skills/gstack/bin/gstack-session-kind | 53 + .../skills/gstack/bin/gstack-session-update | 210 ++ .../skills/gstack/bin/gstack-settings-hook | 851 ++++++ .claude/skills/gstack/bin/gstack-skill-end | 60 + .claude/skills/gstack/bin/gstack-skill-start | 506 ++++ .claude/skills/gstack/bin/gstack-slug | 278 ++ .../skills/gstack/bin/gstack-specialist-stats | 65 + .claude/skills/gstack/bin/gstack-taste-update | 293 ++ .claude/skills/gstack/bin/gstack-team-init | 216 ++ .../skills/gstack/bin/gstack-telemetry-log | 275 ++ .../skills/gstack/bin/gstack-telemetry-sync | 182 ++ .claude/skills/gstack/bin/gstack-timeline-log | 40 + .../skills/gstack/bin/gstack-timeline-read | 96 + .claude/skills/gstack/bin/gstack-uninstall | 402 +++ .claude/skills/gstack/bin/gstack-update-check | 288 ++ .claude/skills/gstack/bin/gstack-verify-gate | 214 ++ .claude/skills/gstack/bin/gstack-version-bump | 528 ++++ .claude/skills/gstack/bin/gstack-wtree | 56 + .claude/skills/gstack/setup | 2535 +++++++++++++++++ .cursor/skills/autoplan | 1 + .cursor/skills/benchmark | 1 + .cursor/skills/benchmark-models | 1 + .cursor/skills/browse | 1 + .cursor/skills/canary | 1 + .cursor/skills/careful | 1 + .cursor/skills/codex | 1 + .cursor/skills/context-restore | 1 + .cursor/skills/context-save | 1 + .cursor/skills/cso | 1 + .cursor/skills/design-consultation | 1 + .cursor/skills/design-html | 1 + .cursor/skills/design-review | 1 + .cursor/skills/design-shotgun | 1 + .cursor/skills/devex-review | 1 + .cursor/skills/diagram | 1 + .cursor/skills/document-generate | 1 + .cursor/skills/document-release | 1 + .cursor/skills/freeze | 1 + .cursor/skills/gstack | 1 + .cursor/skills/gstack-openclaw-ceo-review | 1 + .cursor/skills/gstack-openclaw-investigate | 1 + .cursor/skills/gstack-openclaw-office-hours | 1 + .cursor/skills/gstack-openclaw-retro | 1 + .cursor/skills/gstack-upgrade | 1 + .cursor/skills/guard | 1 + .cursor/skills/hackernews-frontpage | 1 + .cursor/skills/health | 1 + .cursor/skills/investigate | 1 + .cursor/skills/ios-clean | 1 + .cursor/skills/ios-design-review | 1 + .cursor/skills/ios-fix | 1 + .cursor/skills/ios-qa | 1 + .cursor/skills/ios-sync | 1 + .cursor/skills/land-and-deploy | 1 + .cursor/skills/landing-report | 1 + .cursor/skills/learn | 1 + .cursor/skills/make-pdf | 1 + .cursor/skills/office-hours | 1 + .cursor/skills/open-gstack-browser | 1 + .cursor/skills/pair-agent | 1 + .cursor/skills/plan-ceo-review | 1 + .cursor/skills/plan-design-review | 1 + .cursor/skills/plan-devex-review | 1 + .cursor/skills/plan-eng-review | 1 + .cursor/skills/plan-tune | 1 + .cursor/skills/qa | 1 + .cursor/skills/qa-only | 1 + .cursor/skills/retro | 1 + .cursor/skills/review | 1 + .cursor/skills/scrape | 1 + .cursor/skills/setup-browser-cookies | 1 + .cursor/skills/setup-deploy | 1 + .cursor/skills/setup-gbrain | 1 + .cursor/skills/ship | 1 + .cursor/skills/skillify | 1 + .cursor/skills/spec | 1 + .cursor/skills/sync-gbrain | 1 + .cursor/skills/unfreeze | 1 + docs/agent-skill-packs.md | 17 +- docs/agent-skills.md | 4 +- scripts/gen-skills-index.js | 4 +- scripts/install-agent-skills.sh | 45 +- scripts/link-agent-skills.sh | 78 + test/agent-skill-packs.test.js | 3 + test/link-agent-skills.test.js | 116 + 238 files changed, 52555 insertions(+), 11 deletions(-) create mode 100644 .agents/skills/gstack/ETHOS.md create mode 100644 .agents/skills/gstack/NOTICE.md create mode 100644 .agents/skills/gstack/VERSION create mode 100755 .agents/skills/gstack/bin/dev-setup create mode 100755 .agents/skills/gstack/bin/dev-teardown create mode 100755 .agents/skills/gstack/bin/gstack-analytics create mode 100755 .agents/skills/gstack/bin/gstack-artifacts-init create mode 100755 .agents/skills/gstack/bin/gstack-artifacts-url create mode 100755 .agents/skills/gstack/bin/gstack-brain-cache create mode 100644 .agents/skills/gstack/bin/gstack-brain-context-load.ts create mode 100755 .agents/skills/gstack/bin/gstack-brain-enqueue create mode 100755 .agents/skills/gstack/bin/gstack-brain-restore create mode 100755 .agents/skills/gstack/bin/gstack-brain-sync create mode 100755 .agents/skills/gstack/bin/gstack-brain-uninstall create mode 100755 .agents/skills/gstack/bin/gstack-builder-profile create mode 100755 .agents/skills/gstack/bin/gstack-code-intelligence create mode 100755 .agents/skills/gstack/bin/gstack-codex-probe create mode 100755 .agents/skills/gstack/bin/gstack-codex-session-import create mode 100755 .agents/skills/gstack/bin/gstack-community-dashboard create mode 100755 .agents/skills/gstack/bin/gstack-config create mode 100755 .agents/skills/gstack/bin/gstack-context-bill create mode 100755 .agents/skills/gstack/bin/gstack-decision-log create mode 100755 .agents/skills/gstack/bin/gstack-decision-search create mode 100755 .agents/skills/gstack/bin/gstack-detach create mode 100755 .agents/skills/gstack/bin/gstack-developer-profile create mode 100755 .agents/skills/gstack/bin/gstack-diff-scope create mode 100755 .agents/skills/gstack/bin/gstack-distill-apply create mode 100755 .agents/skills/gstack/bin/gstack-distill-free-text create mode 100755 .agents/skills/gstack/bin/gstack-egress create mode 100755 .agents/skills/gstack/bin/gstack-egress-lib.sh create mode 100755 .agents/skills/gstack/bin/gstack-egress-receipt create mode 100755 .agents/skills/gstack/bin/gstack-evidence create mode 100755 .agents/skills/gstack/bin/gstack-extension create mode 100755 .agents/skills/gstack/bin/gstack-first-task-detect create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-detect create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-install create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-lib.sh create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-mcp-verify create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-repo-policy create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-source-wireup create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-supabase-provision create mode 100755 .agents/skills/gstack/bin/gstack-gbrain-supabase-verify create mode 100644 .agents/skills/gstack/bin/gstack-gbrain-sync.ts create mode 100644 .agents/skills/gstack/bin/gstack-global-discover.ts create mode 100755 .agents/skills/gstack/bin/gstack-ios-qa-daemon create mode 100755 .agents/skills/gstack/bin/gstack-ios-qa-mint create mode 100755 .agents/skills/gstack/bin/gstack-ios-qa-regen create mode 100755 .agents/skills/gstack/bin/gstack-issue-guard create mode 100755 .agents/skills/gstack/bin/gstack-jsonl-merge create mode 100755 .agents/skills/gstack/bin/gstack-learnings-log create mode 100755 .agents/skills/gstack/bin/gstack-learnings-search create mode 100644 .agents/skills/gstack/bin/gstack-memory-ingest.ts create mode 100755 .agents/skills/gstack/bin/gstack-model-benchmark create mode 100755 .agents/skills/gstack/bin/gstack-next-version create mode 100755 .agents/skills/gstack/bin/gstack-patch-names create mode 100755 .agents/skills/gstack/bin/gstack-paths create mode 100755 .agents/skills/gstack/bin/gstack-pr-title-rewrite.sh create mode 100755 .agents/skills/gstack/bin/gstack-question-log create mode 100755 .agents/skills/gstack/bin/gstack-question-preference create mode 100755 .agents/skills/gstack/bin/gstack-redact create mode 100755 .agents/skills/gstack/bin/gstack-redact-prepush create mode 100755 .agents/skills/gstack/bin/gstack-relink create mode 100755 .agents/skills/gstack/bin/gstack-repo-mode create mode 100755 .agents/skills/gstack/bin/gstack-retro-metrics create mode 100755 .agents/skills/gstack/bin/gstack-review-log create mode 100755 .agents/skills/gstack/bin/gstack-review-read create mode 100755 .agents/skills/gstack/bin/gstack-security-dashboard create mode 100755 .agents/skills/gstack/bin/gstack-session-kind create mode 100755 .agents/skills/gstack/bin/gstack-session-update create mode 100755 .agents/skills/gstack/bin/gstack-settings-hook create mode 100755 .agents/skills/gstack/bin/gstack-skill-end create mode 100755 .agents/skills/gstack/bin/gstack-skill-start create mode 100755 .agents/skills/gstack/bin/gstack-slug create mode 100755 .agents/skills/gstack/bin/gstack-specialist-stats create mode 100755 .agents/skills/gstack/bin/gstack-taste-update create mode 100755 .agents/skills/gstack/bin/gstack-team-init create mode 100755 .agents/skills/gstack/bin/gstack-telemetry-log create mode 100755 .agents/skills/gstack/bin/gstack-telemetry-sync create mode 100755 .agents/skills/gstack/bin/gstack-timeline-log create mode 100755 .agents/skills/gstack/bin/gstack-timeline-read create mode 100755 .agents/skills/gstack/bin/gstack-uninstall create mode 100755 .agents/skills/gstack/bin/gstack-update-check create mode 100755 .agents/skills/gstack/bin/gstack-verify-gate create mode 100755 .agents/skills/gstack/bin/gstack-version-bump create mode 100755 .agents/skills/gstack/bin/gstack-wtree create mode 100755 .agents/skills/gstack/setup create mode 100644 .claude/skills/gstack/ETHOS.md create mode 100644 .claude/skills/gstack/NOTICE.md create mode 100644 .claude/skills/gstack/VERSION create mode 100755 .claude/skills/gstack/bin/dev-setup create mode 100755 .claude/skills/gstack/bin/dev-teardown create mode 100755 .claude/skills/gstack/bin/gstack-analytics create mode 100755 .claude/skills/gstack/bin/gstack-artifacts-init create mode 100755 .claude/skills/gstack/bin/gstack-artifacts-url create mode 100755 .claude/skills/gstack/bin/gstack-brain-cache create mode 100644 .claude/skills/gstack/bin/gstack-brain-context-load.ts create mode 100755 .claude/skills/gstack/bin/gstack-brain-enqueue create mode 100755 .claude/skills/gstack/bin/gstack-brain-restore create mode 100755 .claude/skills/gstack/bin/gstack-brain-sync create mode 100755 .claude/skills/gstack/bin/gstack-brain-uninstall create mode 100755 .claude/skills/gstack/bin/gstack-builder-profile create mode 100755 .claude/skills/gstack/bin/gstack-code-intelligence create mode 100755 .claude/skills/gstack/bin/gstack-codex-probe create mode 100755 .claude/skills/gstack/bin/gstack-codex-session-import create mode 100755 .claude/skills/gstack/bin/gstack-community-dashboard create mode 100755 .claude/skills/gstack/bin/gstack-config create mode 100755 .claude/skills/gstack/bin/gstack-context-bill create mode 100755 .claude/skills/gstack/bin/gstack-decision-log create mode 100755 .claude/skills/gstack/bin/gstack-decision-search create mode 100755 .claude/skills/gstack/bin/gstack-detach create mode 100755 .claude/skills/gstack/bin/gstack-developer-profile create mode 100755 .claude/skills/gstack/bin/gstack-diff-scope create mode 100755 .claude/skills/gstack/bin/gstack-distill-apply create mode 100755 .claude/skills/gstack/bin/gstack-distill-free-text create mode 100755 .claude/skills/gstack/bin/gstack-egress create mode 100755 .claude/skills/gstack/bin/gstack-egress-lib.sh create mode 100755 .claude/skills/gstack/bin/gstack-egress-receipt create mode 100755 .claude/skills/gstack/bin/gstack-evidence create mode 100755 .claude/skills/gstack/bin/gstack-extension create mode 100755 .claude/skills/gstack/bin/gstack-first-task-detect create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-detect create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-install create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-lib.sh create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-mcp-verify create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-repo-policy create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-source-wireup create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-supabase-provision create mode 100755 .claude/skills/gstack/bin/gstack-gbrain-supabase-verify create mode 100644 .claude/skills/gstack/bin/gstack-gbrain-sync.ts create mode 100644 .claude/skills/gstack/bin/gstack-global-discover.ts create mode 100755 .claude/skills/gstack/bin/gstack-ios-qa-daemon create mode 100755 .claude/skills/gstack/bin/gstack-ios-qa-mint create mode 100755 .claude/skills/gstack/bin/gstack-ios-qa-regen create mode 100755 .claude/skills/gstack/bin/gstack-issue-guard create mode 100755 .claude/skills/gstack/bin/gstack-jsonl-merge create mode 100755 .claude/skills/gstack/bin/gstack-learnings-log create mode 100755 .claude/skills/gstack/bin/gstack-learnings-search create mode 100644 .claude/skills/gstack/bin/gstack-memory-ingest.ts create mode 100755 .claude/skills/gstack/bin/gstack-model-benchmark create mode 100755 .claude/skills/gstack/bin/gstack-next-version create mode 100755 .claude/skills/gstack/bin/gstack-patch-names create mode 100755 .claude/skills/gstack/bin/gstack-paths create mode 100755 .claude/skills/gstack/bin/gstack-pr-title-rewrite.sh create mode 100755 .claude/skills/gstack/bin/gstack-question-log create mode 100755 .claude/skills/gstack/bin/gstack-question-preference create mode 100755 .claude/skills/gstack/bin/gstack-redact create mode 100755 .claude/skills/gstack/bin/gstack-redact-prepush create mode 100755 .claude/skills/gstack/bin/gstack-relink create mode 100755 .claude/skills/gstack/bin/gstack-repo-mode create mode 100755 .claude/skills/gstack/bin/gstack-retro-metrics create mode 100755 .claude/skills/gstack/bin/gstack-review-log create mode 100755 .claude/skills/gstack/bin/gstack-review-read create mode 100755 .claude/skills/gstack/bin/gstack-security-dashboard create mode 100755 .claude/skills/gstack/bin/gstack-session-kind create mode 100755 .claude/skills/gstack/bin/gstack-session-update create mode 100755 .claude/skills/gstack/bin/gstack-settings-hook create mode 100755 .claude/skills/gstack/bin/gstack-skill-end create mode 100755 .claude/skills/gstack/bin/gstack-skill-start create mode 100755 .claude/skills/gstack/bin/gstack-slug create mode 100755 .claude/skills/gstack/bin/gstack-specialist-stats create mode 100755 .claude/skills/gstack/bin/gstack-taste-update create mode 100755 .claude/skills/gstack/bin/gstack-team-init create mode 100755 .claude/skills/gstack/bin/gstack-telemetry-log create mode 100755 .claude/skills/gstack/bin/gstack-telemetry-sync create mode 100755 .claude/skills/gstack/bin/gstack-timeline-log create mode 100755 .claude/skills/gstack/bin/gstack-timeline-read create mode 100755 .claude/skills/gstack/bin/gstack-uninstall create mode 100755 .claude/skills/gstack/bin/gstack-update-check create mode 100755 .claude/skills/gstack/bin/gstack-verify-gate create mode 100755 .claude/skills/gstack/bin/gstack-version-bump create mode 100755 .claude/skills/gstack/bin/gstack-wtree create mode 100755 .claude/skills/gstack/setup create mode 120000 .cursor/skills/autoplan create mode 120000 .cursor/skills/benchmark create mode 120000 .cursor/skills/benchmark-models create mode 120000 .cursor/skills/browse create mode 120000 .cursor/skills/canary create mode 120000 .cursor/skills/careful create mode 120000 .cursor/skills/codex create mode 120000 .cursor/skills/context-restore create mode 120000 .cursor/skills/context-save create mode 120000 .cursor/skills/cso create mode 120000 .cursor/skills/design-consultation create mode 120000 .cursor/skills/design-html create mode 120000 .cursor/skills/design-review create mode 120000 .cursor/skills/design-shotgun create mode 120000 .cursor/skills/devex-review create mode 120000 .cursor/skills/diagram create mode 120000 .cursor/skills/document-generate create mode 120000 .cursor/skills/document-release create mode 120000 .cursor/skills/freeze create mode 120000 .cursor/skills/gstack create mode 120000 .cursor/skills/gstack-openclaw-ceo-review create mode 120000 .cursor/skills/gstack-openclaw-investigate create mode 120000 .cursor/skills/gstack-openclaw-office-hours create mode 120000 .cursor/skills/gstack-openclaw-retro create mode 120000 .cursor/skills/gstack-upgrade create mode 120000 .cursor/skills/guard create mode 120000 .cursor/skills/hackernews-frontpage create mode 120000 .cursor/skills/health create mode 120000 .cursor/skills/investigate create mode 120000 .cursor/skills/ios-clean create mode 120000 .cursor/skills/ios-design-review create mode 120000 .cursor/skills/ios-fix create mode 120000 .cursor/skills/ios-qa create mode 120000 .cursor/skills/ios-sync create mode 120000 .cursor/skills/land-and-deploy create mode 120000 .cursor/skills/landing-report create mode 120000 .cursor/skills/learn create mode 120000 .cursor/skills/make-pdf create mode 120000 .cursor/skills/office-hours create mode 120000 .cursor/skills/open-gstack-browser create mode 120000 .cursor/skills/pair-agent create mode 120000 .cursor/skills/plan-ceo-review create mode 120000 .cursor/skills/plan-design-review create mode 120000 .cursor/skills/plan-devex-review create mode 120000 .cursor/skills/plan-eng-review create mode 120000 .cursor/skills/plan-tune create mode 120000 .cursor/skills/qa create mode 120000 .cursor/skills/qa-only create mode 120000 .cursor/skills/retro create mode 120000 .cursor/skills/review create mode 120000 .cursor/skills/scrape create mode 120000 .cursor/skills/setup-browser-cookies create mode 120000 .cursor/skills/setup-deploy create mode 120000 .cursor/skills/setup-gbrain create mode 120000 .cursor/skills/ship create mode 120000 .cursor/skills/skillify create mode 120000 .cursor/skills/spec create mode 120000 .cursor/skills/sync-gbrain create mode 120000 .cursor/skills/unfreeze create mode 100644 test/link-agent-skills.test.js diff --git a/.agents/skills/gstack/ETHOS.md b/.agents/skills/gstack/ETHOS.md new file mode 100644 index 0000000..3dbd5e5 --- /dev/null +++ b/.agents/skills/gstack/ETHOS.md @@ -0,0 +1,169 @@ +# gstack Builder Ethos + +These are the principles that shape how gstack thinks, recommends, and builds. +They are injected into every workflow skill's preamble automatically. They +reflect what we believe about building software in 2026. + +--- + +## The Golden Age + +A single person with AI can now build what used to take a team of twenty. +The engineering barrier is gone. What remains is taste, judgment, and the +willingness to do the complete thing. + +This is not a prediction — it's happening right now. 10,000+ usable lines of +code per day. 100+ commits per week. Not by a team. By one person, part-time, +using the right tools. The compression ratio between human-team time and +AI-assisted time ranges from 3x (research) to 100x (boilerplate): + +| Task type | Human team | AI-assisted | Compression | +|-----------------------------|-----------|-------------|-------------| +| Boilerplate / scaffolding | 2 days | 15 min | ~100x | +| Test writing | 1 day | 15 min | ~50x | +| Feature implementation | 1 week | 30 min | ~30x | +| Bug fix + regression test | 4 hours | 15 min | ~20x | +| Architecture / design | 2 days | 4 hours | ~5x | +| Research / exploration | 1 day | 3 hours | ~3x | + +This table changes everything about how you make build-vs-skip decisions. +The last 10% of completeness that teams used to skip? It costs seconds now. + +--- + +## 1. Boil the Ocean + +"Don't boil the ocean" was the right advice when engineering time was the +bottleneck. That era is over. AI-assisted coding makes the marginal cost of +completeness near-zero, so the old caution has quietly turned into an excuse. +When the complete implementation costs minutes more than the shortcut — do the +complete thing. Every time. + +**Ocean, lakes first:** The ocean is the destination — 100% test coverage for a +module, full feature implementation, all edge cases, complete error paths. You +get there one lake at a time: each lake is a boilable unit, not the ceiling. +"That's boiling the ocean" is no longer a reason to ship a shortcut — boiling +the ocean is the goal. The only thing still out of scope is genuinely unrelated +work: a multi-quarter platform migration that has nothing to do with the task at +hand. Flag that as separate scope. Boil everything else. + +**Completeness is cheap.** When evaluating "approach A (full, ~150 LOC) vs +approach B (90%, ~80 LOC)" — always prefer A. The 70-line delta costs +seconds with AI coding. "Ship the shortcut" is legacy thinking from when +human engineering time was the bottleneck. + +**Anti-patterns:** +- "Choose B — it covers 90% with less code." (If A is 70 lines more, choose A.) +- "Let's defer tests to a follow-up PR." (Tests are the cheapest lake to boil.) +- "This would take 2 weeks." (Say: "2 weeks human / ~1 hour AI-assisted.") + +Read more: https://garryslist.org/posts/boil-the-ocean + +--- + +## 2. Search Before Building + +The 1000x engineer's first instinct is "has someone already solved this?" not +"let me design it from scratch." Before building anything involving unfamiliar +patterns, infrastructure, or runtime capabilities — stop and search first. +The cost of checking is near-zero. The cost of not checking is reinventing +something worse. + +### Three Layers of Knowledge + +There are three distinct sources of truth when building anything. Understand +which layer you're operating in: + +**Layer 1: Tried and true.** Standard patterns, battle-tested approaches, +things deeply in distribution. You probably already know these. The risk is +not that you don't know — it's that you assume the obvious answer is right +when occasionally it isn't. The cost of checking is near-zero. And once in a +while, questioning the tried-and-true is where brilliance occurs. + +**Layer 2: New and popular.** Current best practices, blog posts, ecosystem +trends. Search for these. But scrutinize what you find — humans are subject +to mania. Mr. Market is either too fearful or too greedy. The crowd can be +wrong about new things just as easily as old things. Search results are inputs +to your thinking, not answers. + +**Layer 3: First principles.** Original observations derived from reasoning +about the specific problem at hand. These are the most valuable of all. Prize +them above everything else. The best projects both avoid mistakes (don't +reinvent the wheel — Layer 1) while also making brilliant observations that +are out of distribution (Layer 3). + +### The Eureka Moment + +The most valuable outcome of searching is not finding a solution to copy. +It is: + +1. Understanding what everyone is doing and WHY (Layers 1 + 2) +2. Applying first-principles reasoning to their assumptions (Layer 3) +3. Discovering a clear reason why the conventional approach is wrong + +This is the 11 out of 10. The truly superlative projects are full of these +moments — zig while others zag. When you find one, name it. Celebrate it. +Build on it. + +**Anti-patterns:** +- Rolling a custom solution when the runtime has a built-in. (Layer 1 miss) +- Accepting blog posts uncritically in novel territory. (Layer 2 mania) +- Assuming tried-and-true is right without questioning premises. (Layer 3 blindness) + +--- + +## 3. User Sovereignty + +AI models recommend. Users decide. This is the one rule that overrides all others. + +Two AI models agreeing on a change is a strong signal. It is not a mandate. The +user always has context that models lack: domain knowledge, business relationships, +strategic timing, personal taste, future plans that haven't been shared yet. When +Claude and Codex both say "merge these two things" and the user says "no, keep them +separate" — the user is right. Always. Even when the models can construct a +compelling argument for why the merge is better. + +Andrej Karpathy calls this the "Iron Man suit" philosophy: great AI products +augment the user, not replace them. The human stays at the center. Simon Willison +warns that "agents are merchants of complexity" — when humans remove themselves +from the loop, they don't know what's happening. Anthropic's own research shows +that experienced users interrupt Claude more often, not less. Expertise makes you +more hands-on, not less. + +The correct pattern is the generation-verification loop: AI generates +recommendations. The user verifies and decides. The AI never skips the +verification step because it's confident. + +**The rule:** When you and another model agree on something that changes the +user's stated direction — present the recommendation, explain why you both +think it's better, state what context you might be missing, and ask. Never act. + +**Anti-patterns:** +- "The outside voice is right, so I'll incorporate it." (Present it. Ask.) +- "Both models agree, so this must be correct." (Agreement is signal, not proof.) +- "I'll make the change and tell the user afterward." (Ask first. Always.) +- Framing your assessment as settled fact in a "My Assessment" column. (Present + both sides. Let the user fill in the assessment.) + +--- + +## How They Work Together + +Boil the Ocean says: **do the complete thing.** +Search Before Building says: **know what exists before you decide what to build.** + +Together: search first, then build the complete version of the right thing. +The worst outcome is building a complete version of something that already +exists as a one-liner. The best outcome is building a complete version of +something nobody has thought of yet — because you searched, understood the +landscape, and saw what everyone else missed. + +--- + +## Build for Yourself + +The best tools solve your own problem. gstack exists because its creator +wanted it. Every feature was built because it was needed, not because it +was requested. If you're building something for yourself, trust that instinct. +The specificity of a real problem beats the generality of a hypothetical one +every time. diff --git a/.agents/skills/gstack/NOTICE.md b/.agents/skills/gstack/NOTICE.md new file mode 100644 index 0000000..8d3aa66 --- /dev/null +++ b/.agents/skills/gstack/NOTICE.md @@ -0,0 +1,6 @@ +gstack skills are nested under this pack for Claude Code / Codex. Cursor slash +commands index one-level skill directories (`.cursor/skills//SKILL.md`). +`scripts/link-agent-skills.sh` flattens each skill into `.cursor/skills/` and +`~/.cursor/skills/` using the SKILL.md `name:` field (e.g. `/plan-ceo-review`). +Do not always-apply the whole suite. Native `./setup --host cursor` requires bun +and is optional; this repo does not run it on boot. diff --git a/.agents/skills/gstack/VERSION b/.agents/skills/gstack/VERSION new file mode 100644 index 0000000..e8df5e2 --- /dev/null +++ b/.agents/skills/gstack/VERSION @@ -0,0 +1 @@ +1.74.0.0 diff --git a/.agents/skills/gstack/bin/dev-setup b/.agents/skills/gstack/bin/dev-setup new file mode 100755 index 0000000..f661b59 --- /dev/null +++ b/.agents/skills/gstack/bin/dev-setup @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Set up gstack for local development — test skills from within this repo. +# +# Creates .claude/skills/gstack → (symlink to repo root) so Claude Code +# discovers skills from your working tree. Changes take effect immediately. +# +# Also copies .env from the main worktree if this is a Conductor workspace +# or git worktree (so API keys carry over automatically). +# +# Usage: bin/dev-setup # set up +# bin/dev-teardown # clean up +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# 1. Copy .env from main worktree (if we're a worktree and don't have one) +if [ ! -f "$REPO_ROOT/.env" ]; then + MAIN_WORKTREE="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')" + if [ -n "$MAIN_WORKTREE" ] && [ "$MAIN_WORKTREE" != "$REPO_ROOT" ] && [ -f "$MAIN_WORKTREE/.env" ]; then + cp "$MAIN_WORKTREE/.env" "$REPO_ROOT/.env" + echo "Copied .env from main worktree ($MAIN_WORKTREE)" + fi +fi + +# 2. Install dependencies +if [ ! -d "$REPO_ROOT/node_modules" ]; then + echo "Installing dependencies..." + (cd "$REPO_ROOT" && bun install) +fi + +# 3. Create .claude/skills/ inside the repo +mkdir -p "$REPO_ROOT/.claude/skills" + +# 4. Symlink .claude/skills/gstack → repo root +# This makes setup think it's inside a real .claude/skills/ directory +GSTACK_LINK="$REPO_ROOT/.claude/skills/gstack" +if [ -L "$GSTACK_LINK" ]; then + echo "Updating existing symlink..." + rm "$GSTACK_LINK" +elif [ -d "$GSTACK_LINK" ]; then + echo "Error: .claude/skills/gstack is a real directory, not a symlink." >&2 + echo "Remove it manually if you want to use dev mode." >&2 + exit 1 +fi +ln -s "$REPO_ROOT" "$GSTACK_LINK" + +# 5. Create .agents/skills/gstack → repo root (for Codex/Gemini/Cursor) +mkdir -p "$REPO_ROOT/.agents/skills" +AGENTS_LINK="$REPO_ROOT/.agents/skills/gstack" +if [ -L "$AGENTS_LINK" ]; then + rm "$AGENTS_LINK" +elif [ -d "$AGENTS_LINK" ]; then + echo "Warning: .agents/skills/gstack is a real directory, skipping." >&2 +fi +if [ ! -e "$AGENTS_LINK" ]; then + ln -s "$REPO_ROOT" "$AGENTS_LINK" +fi + +# 6. Run setup via the symlink so it detects .claude/skills/ as its parent. +# +# Workspace/dev setup MUST be non-interactive: Conductor runs this under a +# forwarded pty, so any `read` in setup (skill-prefix prompt, plan-tune hook +# consent) would hang the workspace forever. Detaching stdin makes every setup +# prompt take its smart non-interactive default (flat skill names, etc.). +# +# `--plan-tune-hooks=prompt` is load-bearing, not redundant: stdin alone only +# suppresses the *prompt* branch. A saved `plan_tune_hooks: yes` or an exported +# GSTACK_PLAN_TUNE_HOOKS=yes would still resolve to "install" and rewrite the +# user's global ~/.claude/settings.json to point at THIS ephemeral worktree — +# which breaks once the workspace is deleted. The flag has highest precedence, +# so it pins resolution to "prompt" (setup's PT_EXPLICIT provenance keeps the +# Conductor auto-opt-in from overriding an explicit flag), and closed stdin +# then makes prompt-mode a no-op skip (no install, no decline marker). +# +# A dev workspace never ADDS hooks to global settings.json. One stated repair +# exception: setup's heal-first pass may PRUNE dead gstack hook entries and +# RE-POINT existing ones at the stable ~/.claude/skills/gstack install — +# strictly convergent repair, never a new registration, and hook registration +# itself is canonical-only (an ephemeral tree path can never be baked in). +# To install the hooks, run `./setup --plan-tune-hooks` directly (outside +# dev-setup). Saved prefix/other config preferences still apply. +# +# GSTACK_SKIP_GBRAIN_REGEN=1 is passed INLINE (not exported) so it scopes to +# exactly this nested setup call and can't leak into any other setup path. It +# tells setup NOT to regenerate the gbrain :user variant into the tracked +# worktree (that would dirty checked-in source). We render it into an untracked +# per-workspace dir below instead. +GSTACK_SKIP_GBRAIN_REGEN=1 "$GSTACK_LINK/setup" --plan-tune-hooks=prompt /dev/null; then + echo "" + echo "gbrain detected — rendering brain-aware skills into .claude/gstack-rendered (workspace-only, untracked)..." + rm -rf "$RENDER_DIR" + if ( cd "$REPO_ROOT" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_DIR" >/dev/null 2>&1 ); then + # Repoint each project-local SKILL.md symlink whose worktree target has a + # rendered counterpart. The skill DIRECTORY name (basename of the symlink + # target's dir) maps to RENDER_DIR//SKILL.md, which is robust to + # frontmatter renames and the gstack- prefix on the link name. + repointed=0 + for skill_link in "$REPO_ROOT"/.claude/skills/*/SKILL.md; do + [ -L "$skill_link" ] || continue + target="$(readlink "$skill_link")" + skilldir="$(basename "$(dirname "$target")")" + rendered="$RENDER_DIR/$skilldir/SKILL.md" + if [ -f "$rendered" ]; then ln -snf "$rendered" "$skill_link"; repointed=$((repointed + 1)); fi + done + echo " $repointed workspace skills now serve brain-aware blocks (worktree stays canonical)." + else + echo " warning: brain-aware render failed — workspace uses canonical skills." + fi +fi + +echo "" +echo "Dev mode active. Skills resolve from this working tree." +echo " .claude/skills/gstack → $REPO_ROOT" +echo " .agents/skills/gstack → $REPO_ROOT" +echo "Edit any SKILL.md and test immediately — no copy/deploy needed." +echo "" +echo "To make brain-aware blocks live across your OTHER projects too, run:" +echo " gstack-config gbrain-refresh" +echo "" +echo "To tear down: bin/dev-teardown" diff --git a/.agents/skills/gstack/bin/dev-teardown b/.agents/skills/gstack/bin/dev-teardown new file mode 100755 index 0000000..06189e1 --- /dev/null +++ b/.agents/skills/gstack/bin/dev-teardown @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Remove local dev skill symlinks. Restores global gstack as the active install. +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +removed=() + +# ─── Clean up .claude/skills/ ───────────────────────────────── +CLAUDE_SKILLS="$REPO_ROOT/.claude/skills" +if [ -d "$CLAUDE_SKILLS" ]; then + for link in "$CLAUDE_SKILLS"/*/; do + name="$(basename "$link")" + [ "$name" = "gstack" ] && continue + if [ -L "${link%/}" ]; then + rm "${link%/}" + removed+=("claude/$name") + fi + done + + if [ -L "$CLAUDE_SKILLS/gstack" ]; then + rm "$CLAUDE_SKILLS/gstack" + removed+=("claude/gstack") + fi + + rmdir "$CLAUDE_SKILLS" 2>/dev/null || true +fi + +# ─── Clean up the untracked brain-aware render (bin/dev-setup step 7) ── +RENDER_DIR="$REPO_ROOT/.claude/gstack-rendered" +if [ -d "$RENDER_DIR" ]; then + rm -rf "$RENDER_DIR" + removed+=("claude/gstack-rendered") +fi +rmdir "$REPO_ROOT/.claude" 2>/dev/null || true + +# ─── Clean up .agents/skills/ ──────────────────────────────── +AGENTS_SKILLS="$REPO_ROOT/.agents/skills" +if [ -d "$AGENTS_SKILLS" ]; then + for link in "$AGENTS_SKILLS"/*/; do + name="$(basename "$link")" + [ "$name" = "gstack" ] && continue + if [ -L "${link%/}" ]; then + rm "${link%/}" + removed+=("agents/$name") + fi + done + + if [ -L "$AGENTS_SKILLS/gstack" ]; then + rm "$AGENTS_SKILLS/gstack" + removed+=("agents/gstack") + fi + + rmdir "$AGENTS_SKILLS" 2>/dev/null || true + rmdir "$REPO_ROOT/.agents" 2>/dev/null || true +fi + +if [ ${#removed[@]} -gt 0 ]; then + echo "Removed: ${removed[*]}" +else + echo "No symlinks found." +fi +echo "Dev mode deactivated. Global gstack (~/.claude/skills/gstack) is now active." diff --git a/.agents/skills/gstack/bin/gstack-analytics b/.agents/skills/gstack/bin/gstack-analytics new file mode 100755 index 0000000..ad06edd --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-analytics @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# gstack-analytics — personal usage dashboard from local JSONL +# +# Usage: +# gstack-analytics # default: last 7 days +# gstack-analytics 7d # last 7 days +# gstack-analytics 30d # last 30 days +# gstack-analytics all # all time +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -uo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" + +# ─── Parse time window ─────────────────────────────────────── +WINDOW="${1:-7d}" +case "$WINDOW" in + 7d) DAYS=7; LABEL="last 7 days" ;; + 30d) DAYS=30; LABEL="last 30 days" ;; + all) DAYS=0; LABEL="all time" ;; + *) DAYS=7; LABEL="last 7 days" ;; +esac + +# ─── Check for data ────────────────────────────────────────── +if [ ! -f "$JSONL_FILE" ]; then + echo "gstack usage — no data yet" + echo "" + echo "Usage data will appear here after you use gstack skills" + echo "with telemetry enabled (gstack-config set telemetry anonymous)." + exit 0 +fi + +TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' ')" +if [ "$TOTAL_LINES" = "0" ]; then + echo "gstack usage — no data yet" + exit 0 +fi + +# ─── Filter by time window ─────────────────────────────────── +if [ "$DAYS" -gt 0 ] 2>/dev/null; then + # Calculate cutoff date + if date -v-1d +%Y-%m-%d >/dev/null 2>&1; then + # macOS date + CUTOFF="$(date -v-${DAYS}d -u +%Y-%m-%dT%H:%M:%SZ)" + else + # GNU date + CUTOFF="$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2000-01-01T00:00:00Z")" + fi + # Filter: skill_run events (new format) OR basic skill events (old format, no event_type) + # Old format: {"skill":"X","ts":"Y","repo":"Z"} (no event_type field) + # New format: {"event_type":"skill_run","skill":"X","ts":"Y",...} + FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" ' + /"ts":"/ { + # Skip hook_fire events + if (/"event":"hook_fire"/) next + # Skip non-skill_run new-format events + if (/"event_type":"/ && !/"event_type":"skill_run"/) next + for (i=1; i<=NF; i++) { + if ($i == "ts" && $(i+1) ~ /^:/) { + ts = $(i+2) + if (ts >= cutoff) { print; break } + } + } + } + ' "$JSONL_FILE")" +else + # All time: include skill_run events + old-format basic events, exclude hook_fire + FILTERED="$(awk '/"ts":"/ && !/"event":"hook_fire"/' "$JSONL_FILE" | grep -v '"event_type":"upgrade_' 2>/dev/null || true)" +fi + +if [ -z "$FILTERED" ]; then + echo "gstack usage ($LABEL) — no skill runs found" + exit 0 +fi + +# ─── Aggregate by skill ────────────────────────────────────── +# Extract skill names and count +SKILL_COUNTS="$(echo "$FILTERED" | awk -F'"' ' + /"skill":"/ { + for (i=1; i<=NF; i++) { + if ($i == "skill" && $(i+1) ~ /^:/) { + skill = $(i+2) + counts[skill]++ + break + } + } + } + END { + for (s in counts) print counts[s], s + } +' | sort -rn)" + +# Count outcomes +TOTAL="$(echo "$FILTERED" | wc -l | tr -d ' ')" +SUCCESS="$(echo "$FILTERED" | grep -c '"outcome":"success"' || true)" +SUCCESS="${SUCCESS:-0}"; SUCCESS="$(echo "$SUCCESS" | tr -d ' \n\r\t')" +ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' || true)" +ERRORS="${ERRORS:-0}"; ERRORS="$(echo "$ERRORS" | tr -d ' \n\r\t')" +# Old format events have no outcome field — count them as successful +NO_OUTCOME="$(echo "$FILTERED" | grep -vc '"outcome":' || true)" +NO_OUTCOME="${NO_OUTCOME:-0}"; NO_OUTCOME="$(echo "$NO_OUTCOME" | tr -d ' \n\r\t')" +SUCCESS=$(( SUCCESS + NO_OUTCOME )) + +# Calculate success rate +if [ "$TOTAL" -gt 0 ] 2>/dev/null; then + SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL )) +else + SUCCESS_RATE=100 +fi + +# ─── Calculate total duration ──────────────────────────────── +TOTAL_DURATION="$(echo "$FILTERED" | awk -F'[:,]' ' + /"duration_s"/ { + for (i=1; i<=NF; i++) { + if ($i ~ /"duration_s"/) { + val = $(i+1) + gsub(/[^0-9.]/, "", val) + if (val+0 > 0) total += val + } + } + } + END { printf "%.0f", total } +')" + +# Format duration +TOTAL_DURATION="${TOTAL_DURATION:-0}" +if [ "$TOTAL_DURATION" -ge 3600 ] 2>/dev/null; then + HOURS=$(( TOTAL_DURATION / 3600 )) + MINS=$(( (TOTAL_DURATION % 3600) / 60 )) + DUR_DISPLAY="${HOURS}h ${MINS}m" +elif [ "$TOTAL_DURATION" -ge 60 ] 2>/dev/null; then + MINS=$(( TOTAL_DURATION / 60 )) + DUR_DISPLAY="${MINS}m" +else + DUR_DISPLAY="${TOTAL_DURATION}s" +fi + +# ─── Render output ─────────────────────────────────────────── +echo "gstack usage ($LABEL)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Find max count for bar scaling +MAX_COUNT="$(echo "$SKILL_COUNTS" | head -1 | awk '{print $1}')" +BAR_WIDTH=20 + +echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do + # Scale bar + if [ "$MAX_COUNT" -gt 0 ] 2>/dev/null; then + BAR_LEN=$(( COUNT * BAR_WIDTH / MAX_COUNT )) + else + BAR_LEN=1 + fi + [ "$BAR_LEN" -lt 1 ] && BAR_LEN=1 + + # Build bar + BAR="" + i=0 + while [ "$i" -lt "$BAR_LEN" ]; do + BAR="${BAR}█" + i=$(( i + 1 )) + done + + # Calculate avg duration for this skill + AVG_DUR="$(echo "$FILTERED" | awk -v skill="$SKILL" ' + index($0, "\"skill\":\"" skill "\"") > 0 { + # Extract duration_s value using split on "duration_s": + n = split($0, parts, "\"duration_s\":") + if (n >= 2) { + # parts[2] starts with the value, e.g. "142," + gsub(/[^0-9.].*/, "", parts[2]) + if (parts[2]+0 > 0) { total += parts[2]; count++ } + } + } + END { if (count > 0) printf "%.0f", total/count; else print "0" } + ')" + + # Format avg duration + if [ "$AVG_DUR" -ge 60 ] 2>/dev/null; then + AVG_DISPLAY="$(( AVG_DUR / 60 ))m" + else + AVG_DISPLAY="${AVG_DUR}s" + fi + + printf " /%-20s %s %d runs (avg %s)\n" "$SKILL" "$BAR" "$COUNT" "$AVG_DISPLAY" +done + +echo "" +echo "Success rate: ${SUCCESS_RATE}% | Errors: ${ERRORS} | Total time: ${DUR_DISPLAY}" +echo "Events: ${TOTAL} skill runs" diff --git a/.agents/skills/gstack/bin/gstack-artifacts-init b/.agents/skills/gstack/bin/gstack-artifacts-init new file mode 100755 index 0000000..6ad2dcf --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-artifacts-init @@ -0,0 +1,490 @@ +#!/usr/bin/env bash +# gstack-artifacts-init — set up ~/.gstack/ as a git repo synced to a private +# git host (GitHub or GitLab) so a remote gbrain can ingest your artifacts +# (CEO plans, designs, /investigate reports) as a federated source. +# +# Replaces gstack-brain-init in v1.27.0.0 (per D4 hard-delete; no compat +# shim). Existing users are migrated by gstack-upgrade/migrations/v1.27.0.0.sh. +# +# Usage: +# gstack-artifacts-init [--remote ] [--host github|gitlab|manual] +# [--push-protocol auto|https|ssh] +# [--url-form-supported true|false] +# +# Interactive by default. Pass --remote to skip the host prompt. +# +# Idempotent: safe to re-run. If ~/.gstack/.git already exists AND points at +# the same remote, reconfigures drivers/hooks/attributes without clobbering +# history. If it points at a DIFFERENT remote, refuses. +# +# What it does: +# 1. git init ~/.gstack/ (or verify existing repo points at the right remote) +# 2. Write .gitignore = "*" (ignore everything; allowlist is explicit) +# 3. Write .brain-allowlist (canonical paths to sync) +# 4. Write .brain-privacy-map.json (paths → privacy class) +# 5. Write .gitattributes (register JSONL + union merge drivers) +# 6. git config merge.jsonl-append.driver + merge.union.driver +# 7. Install .git/hooks/pre-commit (defense-in-depth secret scan) +# 8. Provider-aware repo create (gh / glab) OR manual URL paste +# 9. Initial commit + push +# 10. Write ~/.gstack-artifacts-remote.txt (HTTPS URL — canonical form) +# 11. Print "Send this to your brain admin" hookup command +# +# Env: +# GSTACK_HOME — override ~/.gstack +# USER — fallback for repo naming if $USER is unset + +# 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 -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +URL_BIN="$SCRIPT_DIR/gstack-artifacts-url" +REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" + +# Egress receipt helpers (_receipted_git): fail-open for user-directed +# git ops against the user's own artifacts remote. +. "$SCRIPT_DIR/gstack-egress-lib.sh" + +# remote host for receipt records (github.com etc). Set once PUSH_URL exists. +_artifacts_host() { + local h="${PUSH_URL#*://}"; h="${h#*@}"; h="${h%%[/:]*}" + echo "${h:-unknown}" +} + +REMOTE_URL="" +HOST_PREF="" +PUSH_PROTOCOL="auto" +REMOTE_SOURCE="provider" +URL_FORM_SUPPORTED="false" +while [ $# -gt 0 ]; do + case "$1" in + --remote) REMOTE_URL="$2"; REMOTE_SOURCE="explicit"; shift 2 ;; + --host) HOST_PREF="$2"; shift 2 ;; + --push-protocol) PUSH_PROTOCOL="$2"; shift 2 ;; + --url-form-supported) URL_FORM_SUPPORTED="$2"; shift 2 ;; + --help|-h) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +case "$PUSH_PROTOCOL" in + auto|https|ssh) ;; + *) echo "Invalid --push-protocol: $PUSH_PROTOCOL (expected auto|https|ssh)" >&2; exit 1 ;; +esac + +# ---- preconditions ---- +mkdir -p "$GSTACK_HOME" + +EXISTING_REMOTE="" +if [ -d "$GSTACK_HOME/.git" ]; then + EXISTING_REMOTE=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + if [ -n "$EXISTING_REMOTE" ] && [ -n "$REMOTE_URL" ]; then + # Compare at the canonical level. The stored remote is SSH (for git push), + # the input is usually HTTPS — same logical repo, different surface form. + EXISTING_HTTPS=$("$URL_BIN" --to https "$EXISTING_REMOTE" 2>/dev/null || echo "$EXISTING_REMOTE") + INPUT_HTTPS=$("$URL_BIN" --to https "$REMOTE_URL" 2>/dev/null || echo "$REMOTE_URL") + if [ "$EXISTING_HTTPS" != "$INPUT_HTTPS" ]; then + cat >&2 < +EOF + exit 1 + fi + fi +fi + +# ---- detect available providers ---- +gh_ok=false +glab_ok=false +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then gh_ok=true; fi +if command -v glab >/dev/null 2>&1 && glab auth status >/dev/null 2>&1; then glab_ok=true; fi + +# ---- choose remote URL ---- +if [ -z "$REMOTE_URL" ] && [ -n "$EXISTING_REMOTE" ]; then + REMOTE_URL="$EXISTING_REMOTE" + REMOTE_SOURCE="existing" + echo "Using existing remote: $REMOTE_URL" +fi + +REPO_NAME="gstack-artifacts-${USER:-$(whoami)}" +DESCRIPTION="gstack artifacts (CEO plans, designs, reports) — synced from ~/.gstack/projects/" + +# Decide host preference if not pinned by --host. +if [ -z "$REMOTE_URL" ] && [ -z "$HOST_PREF" ]; then + if $gh_ok && $glab_ok; then + cat >&2 <&2 + read -r CH || CH="" + case "$CH" in + ""|1) HOST_PREF="github" ;; + 2) HOST_PREF="gitlab" ;; + 3) HOST_PREF="manual" ;; + *) echo "Invalid choice: $CH" >&2; exit 1 ;; + esac + elif $gh_ok; then + HOST_PREF="github" + echo "Using GitHub (gh CLI authenticated; glab not available)" >&2 + elif $glab_ok; then + HOST_PREF="gitlab" + echo "Using GitLab (glab CLI authenticated; gh not available)" >&2 + else + HOST_PREF="manual" + echo "(Neither gh nor glab CLI authenticated — falling through to manual URL)" >&2 + fi +fi + +# ---- create repo on chosen host ---- +if [ -z "$REMOTE_URL" ]; then + case "$HOST_PREF" in + github) + echo "Creating GitHub repo: $REPO_NAME ..." + if ! gh repo create "$REPO_NAME" --private --description "$DESCRIPTION" 2>/dev/null; then + # Maybe already exists; try to fetch its URL. + REMOTE_URL=$(gh repo view "$REPO_NAME" --json url -q .url 2>/dev/null || echo "") + if [ -z "$REMOTE_URL" ]; then + echo "Failed to create or find '$REPO_NAME'. Try --remote ." >&2 + exit 1 + fi + echo "Repo already exists; using $REMOTE_URL" + else + REMOTE_URL=$(gh repo view "$REPO_NAME" --json url -q .url 2>/dev/null || echo "") + fi + ;; + gitlab) + echo "Creating GitLab repo: $REPO_NAME ..." + if ! glab repo create "$REPO_NAME" --private --description "$DESCRIPTION" 2>/dev/null; then + REMOTE_URL=$(glab repo view "$REPO_NAME" -F json 2>/dev/null | jq -r '.web_url // empty' 2>/dev/null || echo "") + if [ -z "$REMOTE_URL" ]; then + echo "Failed to create or find '$REPO_NAME'. Try --remote ." >&2 + exit 1 + fi + echo "Repo already exists; using $REMOTE_URL" + else + REMOTE_URL=$(glab repo view "$REPO_NAME" -F json 2>/dev/null | jq -r '.web_url // empty' 2>/dev/null || echo "") + fi + ;; + manual) + echo "(provide a private git URL)" + printf "Paste an HTTPS git URL (e.g. https://github.com/you/gstack-artifacts.git): " >&2 + read -r REMOTE_URL || REMOTE_URL="" + if [ -z "$REMOTE_URL" ]; then + echo "No URL provided. Aborting." >&2 + exit 1 + fi + REMOTE_SOURCE="manual" + ;; + *) echo "Unknown --host: $HOST_PREF (expected github|gitlab|manual)" >&2; exit 1 ;; + esac +fi + +# ---- canonicalize to HTTPS form ---- +# We store HTTPS in ~/.gstack-artifacts-remote.txt (codex Finding #10: +# canonical form, derive the configured push form via gstack-artifacts-url). +# Unrecognized forms (local bare paths, file:// URLs, self-hosted gitea, etc.) +# pass through verbatim so unusual remotes still work. +CANONICAL_HTTPS=$("$URL_BIN" --to https "$REMOTE_URL" 2>/dev/null || echo "") +if [ -z "$CANONICAL_HTTPS" ]; then + CANONICAL_HTTPS="$REMOTE_URL" +fi + +# Choose the push protocol without overriding an explicit URL. Provider-created +# remotes honor the provider CLI's git protocol; GitHub CLI defaults to HTTPS. +# Unknown/local URL forms pass through unchanged. +RESOLVED_PUSH_PROTOCOL="$PUSH_PROTOCOL" +if [ "$RESOLVED_PUSH_PROTOCOL" = "auto" ]; then + case "$REMOTE_SOURCE" in + explicit|existing|manual) + case "$REMOTE_URL" in + git@*|ssh://*) RESOLVED_PUSH_PROTOCOL="ssh" ;; + http://*|https://*) RESOLVED_PUSH_PROTOCOL="https" ;; + *) RESOLVED_PUSH_PROTOCOL="preserve" ;; + esac + ;; + provider) + CONFIGURED_PROTOCOL="" + case "$HOST_PREF" in + github) CONFIGURED_PROTOCOL=$(gh config get git_protocol 2>/dev/null || echo "") ;; + gitlab) CONFIGURED_PROTOCOL=$(glab config get git_protocol 2>/dev/null || echo "") ;; + esac + case "$CONFIGURED_PROTOCOL" in + ssh|https) RESOLVED_PUSH_PROTOCOL="$CONFIGURED_PROTOCOL" ;; + *) RESOLVED_PUSH_PROTOCOL="https" ;; + esac + ;; + esac +fi + +if [ "$RESOLVED_PUSH_PROTOCOL" = "preserve" ]; then + PUSH_URL="$REMOTE_URL" +else + PUSH_URL=$("$URL_BIN" --to "$RESOLVED_PUSH_PROTOCOL" "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS") +fi + +# ---- verify push URL is reachable ---- +echo "Verifying remote connectivity: $PUSH_URL" +if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-remote-ls-remote "user ran gstack-artifacts-init" \ + bash -c 'git ls-remote "$1" >/dev/null 2>&1' _ "$PUSH_URL"; then + cat >&2 </dev/null || git -C "$GSTACK_HOME" init -q + git -C "$GSTACK_HOME" branch -M main 2>/dev/null || true +fi + +if [ -z "$(git -C "$GSTACK_HOME" remote 2>/dev/null)" ]; then + git -C "$GSTACK_HOME" remote add origin "$PUSH_URL" +else + git -C "$GSTACK_HOME" remote set-url origin "$PUSH_URL" +fi + +# ---- write canonical files (idempotent) ---- +cat > "$GSTACK_HOME/.gitignore" <<'EOF' +# gstack-artifacts sync: ignore-everything base. Paths are included explicitly via +# .brain-allowlist and `git add -f` from gstack-brain-sync. Do not edit. +* +EOF + +cat > "$GSTACK_HOME/.brain-allowlist" <<'EOF' +# Canonical allowlist of paths that gstack-brain-sync will publish. +# One glob per line. Anything not matching stays local. +# Do not edit directly; managed by gstack-artifacts-init. User additions go +# below the marker and survive re-init. +projects/*/learnings.jsonl +projects/*/*-reviews.jsonl +projects/*/ceo-plans/*.md +projects/*/ceo-plans/*/*.md +projects/*/designs/*.md +projects/*/designs/*/*.md +# Project-root design / test-plan artifacts written by /office-hours, +# /plan-eng-review, and /autoplan. The skills emit +# `{user}-{branch}-design-{datetime}.md`, +# `{user}-{branch}-test-plan-{datetime}.md`, and +# `{user}-{branch}-eng-review-test-plan-{datetime}.md` at the project +# root (not under designs/), so the existing `designs/*.md` patterns +# miss them. Without these the cross-machine pull on machine B gets +# the referencing CEO plan but not the underlying design / test plan +# (#1452). +projects/*/*-design-*.md +projects/*/*-test-plan-*.md +projects/*/*-eng-review-test-plan-*.md +projects/*/timeline.jsonl +# The decision store. gstack-decision-log enqueues projects//decisions.jsonl +# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected +# all of them at its "must match at least one allowlist glob" check -- a writer +# enqueueing a path the syncer is guaranteed to drop. Without these the durable +# decision ledger never leaves the machine, on any platform. +projects/*/decisions.jsonl +projects/*/decisions.active.json +projects/*/decisions.archive.jsonl +retros/*.md +developer-profile.json +builder-journey.md +builder-profile.jsonl +# Transcripts staged in remote-http MCP mode (per plan D11 split-engine). +# gstack-memory-ingest persists per-run dirs here when local gbrain import +# is skipped; brain admin pulls + indexes into the remote brain. +transcripts/run-*/*.md +transcripts/run-*/**/*.md +# NOT synced (machine-local UX state): +# projects/*/question-preferences.json (per-machine UX preferences) +# projects/*/question-log.jsonl (audit/derivation log stays with preferences) +# projects/*/question-events.jsonl (same) +# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed) +EOF + +cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' +[ + {"pattern": "projects/*/learnings.jsonl", "class": "artifact"}, + {"pattern": "projects/*/*-reviews.jsonl", "class": "artifact"}, + {"pattern": "projects/*/ceo-plans/*.md", "class": "artifact"}, + {"pattern": "projects/*/ceo-plans/*/*.md", "class": "artifact"}, + {"pattern": "projects/*/designs/*.md", "class": "artifact"}, + {"pattern": "projects/*/designs/*/*.md", "class": "artifact"}, + {"pattern": "projects/*/*-design-*.md", "class": "artifact"}, + {"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/decisions.jsonl", "class": "artifact"}, + {"pattern": "projects/*/decisions.active.json", "class": "artifact"}, + {"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"}, + {"pattern": "retros/*.md", "class": "artifact"}, + {"pattern": "builder-journey.md", "class": "artifact"}, + {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, + {"pattern": "developer-profile.json", "class": "behavioral"}, + {"pattern": "builder-profile.jsonl", "class": "behavioral"}, + {"pattern": "transcripts/run-*/*.md", "class": "behavioral"}, + {"pattern": "transcripts/run-*/**/*.md", "class": "behavioral"} +] +EOF + +cat > "$GSTACK_HOME/.gitattributes" <<'EOF' +# gstack-artifacts: merge drivers for cross-machine sync conflicts. +*.jsonl merge=jsonl-append +retros/*.md merge=union +projects/*/designs/**/*.md merge=union +projects/*/ceo-plans/**/*.md merge=union +projects/*/*-design-*.md merge=union +projects/*/*-test-plan-*.md merge=union +EOF + +# ---- register merge drivers in local git config ---- +git -C "$GSTACK_HOME" config merge.jsonl-append.driver "$SCRIPT_DIR/gstack-jsonl-merge %O %A %B" +git -C "$GSTACK_HOME" config merge.jsonl-append.name "gstack JSONL append-only merger" +git -C "$GSTACK_HOME" config merge.union.driver "cat %A %B > %A.merged && mv %A.merged %A" +git -C "$GSTACK_HOME" config merge.union.name "union concat" + +# ---- install pre-commit hook (defense-in-depth) ---- +HOOK="$GSTACK_HOME/.git/hooks/pre-commit" +mkdir -p "$(dirname "$HOOK")" +cat > "$HOOK" <<'HOOK_EOF' +#!/usr/bin/env bash +# gstack-artifacts pre-commit hook — secret-scan defense-in-depth. +# The primary scanner runs inside gstack-brain-sync BEFORE staging. This hook +# catches any manual `git commit` a user might accidentally run against the +# artifacts repo. +set -uo pipefail + +python3 -c " +import sys, re, subprocess +try: + out = subprocess.check_output(['git', 'diff', '--cached'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace') +except Exception: + sys.exit(0) + +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', + re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\s*:\s*\"[A-Za-z0-9_./+=-]{16,}\"', + re.IGNORECASE)), +] +for name, rx in patterns: + if rx.search(out): + sys.stderr.write(f'gstack-artifacts pre-commit: refusing commit — {name} detected in staged diff.\n') + sys.stderr.write('Either edit the offending file, or if intentional, run:\n') + sys.stderr.write(' gstack-brain-sync --skip-file (to permanently exclude)\n') + sys.exit(1) +sys.exit(0) +" +HOOK_EOF +chmod +x "$HOOK" + +# ---- initial commit (idempotent) ---- +cd "$GSTACK_HOME" +git add -f .gitignore .brain-allowlist .brain-privacy-map.json .gitattributes +if git rev-parse HEAD >/dev/null 2>&1; then + if ! git diff --cached --quiet 2>/dev/null; then + git -c user.email="gstack@localhost" -c user.name="gstack-artifacts-init" \ + commit -q -m "chore: gstack-artifacts-init (refresh sync config)" + fi +else + git -c user.email="gstack@localhost" -c user.name="gstack-artifacts-init" \ + commit -q -m "chore: gstack-artifacts-init" +fi + +# ---- initial push ---- +if ! _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-initial-push "user ran gstack-artifacts-init" \ + bash -c 'git push -q -u origin main 2>/dev/null'; then + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-fetch "user ran gstack-artifacts-init" \ + bash -c 'git fetch origin 2>/dev/null' \ + && _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-pull "user ran gstack-artifacts-init" \ + bash -c 'git pull --ff-only origin "$1" 2>/dev/null' _ "$CURRENT_BRANCH"; then + _receipted_git open artifacts-init "$(_artifacts_host)" artifacts-initial-push "user ran gstack-artifacts-init" \ + git push -q -u origin "$CURRENT_BRANCH" || { + echo "Push to $PUSH_URL failed. The remote may have divergent content." >&2 + echo "Try: cd ~/.gstack && git pull --rebase origin $CURRENT_BRANCH && git push origin $CURRENT_BRANCH" >&2 + exit 1 + } + else + echo "Push to $PUSH_URL failed and fetch/merge didn't help." >&2 + echo "Manual recovery: cd ~/.gstack && git status, then push once conflicts are resolved." >&2 + exit 1 + fi +fi + +# ---- write the remote-url helper file (HTTPS canonical) ---- +echo "$CANONICAL_HTTPS" > "$REMOTE_FILE" +chmod 600 "$REMOTE_FILE" + +# ---- print brain-admin hookup command (always print, never auto-execute; +# codex Finding #3) ---- +SOURCE_ID="gstack-artifacts-${USER:-$(whoami)}" +cat < # https → git@host:owner/repo.git +# gstack-artifacts-url --to https # idempotent canonicalization +# gstack-artifacts-url --host # extract hostname +# gstack-artifacts-url --owner-repo # extract owner/repo +# +# Inputs accepted: +# https://github.com/garrytan/gstack-artifacts-garrytan +# https://github.com/garrytan/gstack-artifacts-garrytan.git +# git@github.com:garrytan/gstack-artifacts-garrytan.git +# ssh://git@gitlab.com/garrytan/gstack-artifacts-garrytan.git +# git@gitlab.example.org:team/gstack-artifacts-team.git +# +# Output: the requested form on stdout. Exits non-zero on parse failure with +# an error on stderr. +set -euo pipefail + +usage() { + echo "Usage: gstack-artifacts-url --to {ssh|https} " >&2 + echo " gstack-artifacts-url --host " >&2 + echo " gstack-artifacts-url --owner-repo " >&2 + exit 2 +} + +[ $# -ge 2 ] || usage + +mode="" +to="" +case "$1" in + --to) mode="to"; to="$2"; shift 2 ;; + --host) mode="host"; shift ;; + --owner-repo) mode="owner-repo"; shift ;; + *) usage ;; +esac + +[ $# -eq 1 ] || usage +url="$1" + +# Strip trailing .git for normalization; reattach where needed. +strip_git() { + echo "${1%.git}" +} + +valid_owner_repo() { + local owner_repo="$1" + case "$owner_repo" in + ""|/*|*/|*//*) + return 1 + ;; + esac + case "$owner_repo" in + */*) return 0 ;; + *) return 1 ;; + esac +} + +# Parse to (host, owner_repo) regardless of input shape. +parse_url() { + local u="$1" + local host="" owner_repo="" + case "$u" in + https://*) + # https://host/owner/repo[.git] + local rest="${u#https://}" + host="${rest%%/*}" + owner_repo="${rest#*/}" + owner_repo=$(strip_git "$owner_repo") + ;; + ssh://*) + # ssh://git@host/owner/repo[.git] OR ssh://host/owner/repo[.git] + local rest="${u#ssh://}" + # Strip optional user@ + rest="${rest#*@}" + host="${rest%%/*}" + owner_repo="${rest#*/}" + owner_repo=$(strip_git "$owner_repo") + ;; + git@*:*) + # git@host:owner/repo[.git] + local rest="${u#git@}" + host="${rest%%:*}" + owner_repo="${rest#*:}" + owner_repo=$(strip_git "$owner_repo") + ;; + *) + echo "gstack-artifacts-url: unrecognized URL form: $u" >&2 + exit 3 + ;; + esac + if [ -z "$host" ] || ! valid_owner_repo "$owner_repo"; then + echo "gstack-artifacts-url: failed to parse host/owner from: $u" >&2 + exit 3 + fi + printf '%s\n%s\n' "$host" "$owner_repo" +} + +parsed=$(parse_url "$url") +host=$(echo "$parsed" | head -1) +owner_repo=$(echo "$parsed" | tail -1) + +case "$mode" in + to) + case "$to" in + ssh) printf 'git@%s:%s.git\n' "$host" "$owner_repo" ;; + https) printf 'https://%s/%s\n' "$host" "$owner_repo" ;; + *) usage ;; + esac + ;; + host) printf '%s\n' "$host" ;; + owner-repo) printf '%s\n' "$owner_repo" ;; +esac diff --git a/.agents/skills/gstack/bin/gstack-brain-cache b/.agents/skills/gstack/bin/gstack-brain-cache new file mode 100755 index 0000000..5ced064 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-brain-cache @@ -0,0 +1,1045 @@ +#!/usr/bin/env bun +/** + * gstack-brain-cache — three-tier cache for brain-aware planning skills. + * + * Subcommands: + * get [--project ] — return digest content; refresh if stale + * refresh [--full] [--entity X] [--project ] — force refresh one or all + * invalidate [--project ] — mark stale; next get triggers cold + * digest — compress a brain page slug to digest + * meta [--project ] — print _meta.json + * + * (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].) + * + * Cache layout: + * ~/.gstack/brain-cache/ ← cross-project (user-profile only) + * ~/.gstack/projects//brain-cache/ ← per-project (everything else) + * + * Atomic writes via .tmp + rename. Stale-but-usable fallback when brain + * unreachable. Concurrent-refresh dedup is a follow-up commit (T15). + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync, openSync, closeSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir, hostname } from 'os'; +import { spawnSync } from 'child_process'; +import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec'; +import { + BRAIN_CACHE_ENTITIES, + CACHE_REFRESH_LOCK_TIMEOUT_MS, + GSTACK_SCHEMA_PACK_NAME, + GSTACK_SCHEMA_PACK_VERSION, + SALIENCE_DEFAULT_ALLOWLIST, + type BrainCacheEntity, +} from '../scripts/brain-cache-spec'; + +// ────────────────────────────────────────────────────────────────────────── +// Paths + meta +// ────────────────────────────────────────────────────────────────────────── + +const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack'); + +interface CacheMeta { + /** Version of the schema pack the cache was built against. Mismatch → full rebuild. */ + schema_version: string; + /** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */ + endpoint_hash: string; + /** Per-entity last-refresh epoch ms. Absent → never refreshed. */ + last_refresh: Record; + /** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */ + last_attempt?: Record; +} + +/** Returns the directory holding a given entity's cache file. */ +export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string { + if (entity.scope === 'cross-project') { + return join(GSTACK_HOME, 'brain-cache'); + } + if (!projectSlug) { + throw new Error(`Per-project entity needs a project slug: ${entity.file}`); + } + return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache'); +} + +/** Returns the path to the cache file for a given entity. */ +export function entityPath(entityName: string, projectSlug: string | null): string { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`); + return join(entityDir(entity, projectSlug), entity.file); +} + +/** Returns the path to the _meta.json for a given scope. */ +export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string { + if (scope === 'cross-project') { + return join(GSTACK_HOME, 'brain-cache', '_meta.json'); + } + if (!projectSlug) throw new Error('Per-project meta needs a project slug'); + return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json'); +} + +function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta { + const path = metaPath(scope, projectSlug); + if (!existsSync(path)) { + return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; + } + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown; + // #1879: a valid JSON file can still be the wrong shape. JSON.parse can return + // null/array/string/number, and a partial object can omit last_refresh — three + // consumers (isStale, cmdInvalidate, refreshEntity) dereference meta.last_refresh + // unguarded and crash with a TypeError. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; + } + const meta = parsed as CacheMeta; + // Normalize ONLY the dereferenced maps. Do NOT default schema_version / + // endpoint_hash — leaving them absent makes schemaVersionMismatch() / + // endpointSwitched() correctly force a rebuild (missing identity = mismatch = + // safe). Defaulting them to current values would suppress invalidation and + // trust a stale file of unknown provenance. + meta.last_refresh = meta.last_refresh ?? {}; + meta.last_attempt = meta.last_attempt ?? {}; + return meta; + } catch { + // Corrupt _meta — start fresh (entries will refresh on next access). + return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} }; + } +} + +function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void { + const path = metaPath(scope, projectSlug); + mkdirSync(dirname(path), { recursive: true }); + atomicWrite(path, JSON.stringify(meta, null, 2)); +} + +// ────────────────────────────────────────────────────────────────────────── +// Endpoint hash detection +// ────────────────────────────────────────────────────────────────────────── + +import { createHash } from 'crypto'; + +function sha8(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 8); +} + +/** + * Detects the active brain endpoint (MCP URL or 'local') and returns its + * stable identity hash. Used to detect when the user switches brains + * (different endpoint → different cache). + * + * Reads BOTH registration scopes in ~/.claude.json (#2499): project scope + * (.projects["/abs/path"].mcpServers.gbrain — what `claude mcp add` + * WITHOUT --scope user writes) first, preferring the nearest ancestor of + * cwd (longest matching project key) so nested repos resolve to their own + * brain, then user scope (.mcpServers.gbrain) as the fallback. That order + * is Claude Code's own name-conflict precedence (local beats user) — + * verified empirically against claude 2.1.233 with a hermetic fake $HOME: + * `claude mcp get gbrain` reports "Scope: Local config" and the + * project-local URL when both scopes define the name — so the hash tracks + * the endpoint the project actually talks to. Before the project-scope + * read, two different project-scoped brains both hashed to 'local', so + * switching between them never invalidated the cache — the exact scenario + * this function exists to catch. + * + * Params exist for tests; production callers use the defaults. + */ +export function detectEndpointHash( + claudeJsonPath: string = join(homedir(), '.claude.json'), + cwd: string = process.cwd(), +): string { + if (existsSync(claudeJsonPath)) { + try { + const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); + const gbrainServer = resolveGbrainMcpEntry(cfg, cwd); + const url = gbrainServer?.url || gbrainServer?.transport?.url; + if (typeof url === 'string' && url.length > 0) { + return sha8(url); + } + } catch { /* fall through to local */ } + } + // Local engine — no endpoint URL; use a stable literal hash. + return 'local'; +} + +interface McpEntryish { + url?: unknown; + transport?: { url?: unknown }; +} + +/** + * Nearest-ancestor project-scope gbrain entry for cwd, else the user-scope + * entry (#2499). Project-local first — Claude Code's own precedence for a + * same-name conflict (see detectEndpointHash's docstring for the empirical + * evidence). Path-boundary-aware: /a/repo never matches /a/repo2. Both + * separators are accepted so Windows project keys resolve. + */ +function resolveGbrainMcpEntry( + cfg: unknown, + cwd: string, +): McpEntryish | undefined { + const root = cfg as { + mcpServers?: Record; + projects?: Record }>; + } | null; + const projects = root?.projects; + if (projects && typeof projects === 'object') { + let best: { key: string; entry: McpEntryish } | undefined; + for (const [key, val] of Object.entries(projects)) { + if (!val || typeof val !== 'object') continue; + const entry = val.mcpServers?.gbrain; + if (!entry || typeof entry !== 'object') continue; + const isAncestor = + cwd === key || cwd.startsWith(`${key}/`) || cwd.startsWith(`${key}\\`); + if (!isAncestor) continue; + if (!best || key.length > best.key.length) best = { key, entry }; + } + if (best) return best.entry; + } + return root?.mcpServers?.gbrain; +} + +// ────────────────────────────────────────────────────────────────────────── +// Atomic write (tmp + rename) +// ────────────────────────────────────────────────────────────────────────── + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tmp, content, 'utf-8'); + renameSync(tmp, path); +} + +// ────────────────────────────────────────────────────────────────────────── +// Staleness + refresh logic +// ────────────────────────────────────────────────────────────────────────── + +/** Returns true if the cached digest is past its TTL. */ +function isStale(entityName: string, meta: CacheMeta): boolean { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) return true; + const last = meta.last_refresh[entityName]; + if (!last) return true; + return Date.now() - last > entity.ttl_ms; +} + +/** Returns true if the cache file exists on disk. */ +function hasFile(entityName: string, projectSlug: string | null): boolean { + return existsSync(entityPath(entityName, projectSlug)); +} + +/** Returns true if schema version recorded in meta differs from current pack version. */ +function schemaVersionMismatch(meta: CacheMeta): boolean { + return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION; +} + +/** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */ +function endpointSwitched(meta: CacheMeta): boolean { + return meta.endpoint_hash !== detectEndpointHash(); +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: get +// ────────────────────────────────────────────────────────────────────────── + +interface GetResult { + /** Path to the digest file. */ + path: string; + /** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */ + state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing'; + /** Optional message for diagnostics. */ + message?: string; +} + +export function cmdGet(entityName: string, projectSlug: string | null): GetResult { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) throw new Error(`Unknown entity: ${entityName}`); + const scope = entity.scope; + const meta = loadMeta(scope, projectSlug); + + // Schema-version mismatch → full rebuild (D4 A4). + if (schemaVersionMismatch(meta) || endpointSwitched(meta)) { + rebuildAllForScope(scope, projectSlug); + // After rebuild, meta is fresh; fall through to warm path. + const newMeta = loadMeta(scope, projectSlug); + if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) { + return { path: entityPath(entityName, projectSlug), state: 'warm' }; + } + // Rebuild may have failed for this entity specifically. + return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' }; + } + + if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) { + return { path: entityPath(entityName, projectSlug), state: 'warm' }; + } + + // Stale or missing — try cold refresh. + const refreshed = refreshEntity(entityName, projectSlug); + if (refreshed) { + return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' }; + } + // Refresh failed. Use stale-but-usable if file exists. + if (hasFile(entityName, projectSlug)) { + return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' }; + } + // No cache and no refresh = missing. + return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: refresh +// ────────────────────────────────────────────────────────────────────────── + +// ────────────────────────────────────────────────────────────────────────── +// Lockfile dedup (T15 / D3) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Returns the lock file path for a project scope. Cross-project entities + * still lock per-project (the project triggering the refresh holds the lock); + * concurrent attempts from different projects on cross-project entities + * serialize naturally because they're rare and the lock window is short. + */ +function lockPath(projectSlug: string | null): string { + const dir = projectSlug + ? join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache') + : join(GSTACK_HOME, 'brain-cache'); + return join(dir, '.refresh.lock'); +} + +interface LockHandle { + fd: number; + path: string; +} + +/** + * Try to acquire the refresh lock. Returns null when another process holds it + * (and the lock is fresh). Stale locks (process dead OR older than the + * timeout) are taken over. + */ +function tryAcquireLock(projectSlug: string | null): LockHandle | null { + const path = lockPath(projectSlug); + mkdirSync(dirname(path), { recursive: true }); + + // If a lock exists, see if it's stale + if (existsSync(path)) { + try { + const raw = readFileSync(path, 'utf-8'); + const lock = JSON.parse(raw) as { pid: number; host: string; ts: number }; + const age = Date.now() - lock.ts; + const sameHost = lock.host === hostname(); + const processGone = sameHost && lock.pid > 0 && !isPidAlive(lock.pid); + if (age <= CACHE_REFRESH_LOCK_TIMEOUT_MS && !processGone) { + return null; // someone else holds a fresh lock + } + // Stale: take over + } catch { + // Corrupt lock file → take over + } + } + + // Write our lock (best-effort O_EXCL via tmp+rename for atomic creation) + const payload = JSON.stringify({ pid: process.pid, host: hostname(), ts: Date.now() }); + const tmp = `${path}.tmp.${process.pid}.${Date.now()}`; + try { + writeFileSync(tmp, payload); + renameSync(tmp, path); + } catch (err) { + return null; + } + + // Race: another process may have raced us. Re-read and verify ownership. + try { + const raw = readFileSync(path, 'utf-8'); + const lock = JSON.parse(raw) as { pid: number; host: string }; + if (lock.pid !== process.pid || lock.host !== hostname()) { + return null; + } + } catch { + return null; + } + return { fd: -1, path }; +} + +function releaseLock(handle: LockHandle): void { + try { unlinkSync(handle.path); } catch { /* best effort */ } +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: any) { + if (err?.code === 'EPERM') return true; // exists but we don't own it + return false; + } +} + +/** + * Run a refresh callback under the project-scoped lock. If another refresh is + * already in flight, returns 'dedup' and the caller can either wait + retry + * (the resolver does this) or fall through to stale-but-usable. Stale locks + * (process dead, or older than CACHE_REFRESH_LOCK_TIMEOUT_MS) are taken over. + */ +export function withRefreshLock(projectSlug: string | null, fn: () => T): T | 'dedup' { + const handle = tryAcquireLock(projectSlug); + if (!handle) return 'dedup'; + try { + return fn(); + } finally { + releaseLock(handle); + } +} + +/** Refreshes one entity from the brain. Returns true on success. */ +export function refreshEntity(entityName: string, projectSlug: string | null): boolean { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) return false; + + // Mark attempt + const meta = loadMeta(entity.scope, projectSlug); + meta.last_attempt = meta.last_attempt || {}; + meta.last_attempt[entityName] = Date.now(); + + // Fetch from brain. The actual fetch logic varies per entity — derived digests + // (recent-decisions, salience) need different queries from direct page reads. + // For T2a we implement the direct-page path; derived digests get filled in by + // the resolver / write-back paths in later commits. + const digestContent = fetchAndCompressEntity(entityName, projectSlug); + if (digestContent === null) { + saveMeta(entity.scope, projectSlug, meta); + return false; + } + + // Enforce per-entity budget by truncating from end (oldest items live there + // by convention in our compressor). The per-skill budget is separately + // enforced at preflight injection time. + let final = digestContent; + if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) { + final = truncateToBudget(final, entity.budget_bytes); + } + + atomicWrite(entityPath(entityName, projectSlug), final); + meta.last_refresh[entityName] = Date.now(); + // Keep schema/endpoint identity fresh. + meta.schema_version = GSTACK_SCHEMA_PACK_VERSION; + meta.endpoint_hash = detectEndpointHash(); + saveMeta(entity.scope, projectSlug, meta); + return true; +} + +/** + * Refresh all entities for a scope (per-project or cross-project). + * Used by --full and by schema/endpoint-change rebuilds. + */ +export function refreshAll(projectSlug: string | null): { success: number; failed: number } { + let success = 0; + let failed = 0; + for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { + // Cross-project entities only refresh when explicitly targeted via no-slug calls + if (entity.scope === 'cross-project' && projectSlug) continue; + if (entity.scope === 'per-project' && !projectSlug) continue; + if (refreshEntity(name, projectSlug)) success++; else failed++; + } + return { success, failed }; +} + +/** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */ +function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void { + // Wipe files but preserve dir; meta gets fully rewritten by refreshes below. + for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { + if (entity.scope !== scope) continue; + const p = entityPath(name, projectSlug); + if (existsSync(p)) { + try { unlinkSync(p); } catch { /* best effort */ } + } + } + // Fresh meta starts here + const fresh: CacheMeta = { + schema_version: GSTACK_SCHEMA_PACK_VERSION, + endpoint_hash: detectEndpointHash(), + last_refresh: {}, + last_attempt: {}, + }; + saveMeta(scope, projectSlug, fresh); + // Refresh all entities in this scope + for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) { + if (entity.scope !== scope) continue; + refreshEntity(name, projectSlug); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: invalidate +// ────────────────────────────────────────────────────────────────────────── + +export function cmdInvalidate(entityName: string, projectSlug: string | null): void { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) throw new Error(`Unknown entity: ${entityName}`); + const meta = loadMeta(entity.scope, projectSlug); + delete meta.last_refresh[entityName]; + saveMeta(entity.scope, projectSlug, meta); +} + +// ────────────────────────────────────────────────────────────────────────── +// Fetch + compress per-entity +// ────────────────────────────────────────────────────────────────────────── + +/** + * Returns the digest markdown content for an entity, or null if the brain is + * unreachable / the source page doesn't exist. + * + * For T2a we implement the entity → page-slug mapping for the simple cases. + * Derived digests (recent-decisions, salience) get specialized paths. + */ +function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null { + switch (entityName) { + case 'user-profile': + return fetchUserProfile(); + case 'product': + return fetchProduct(projectSlug); + case 'goals': + return fetchGoals(projectSlug); + case 'developer-persona': + return fetchSimplePage(`gstack/developer-persona/${projectSlug}`); + case 'brand': + return fetchSimplePage(`gstack/brand/${projectSlug}`); + case 'competitive-intel': + return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`); + case 'recent-decisions': + return fetchRecentDecisions(projectSlug); + case 'salience': + // D9 salience allowlist applied in T17 commit; T2a returns raw output for now. + return fetchSalience(projectSlug); + default: + return null; + } +} + +/** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */ +function fetchSimplePage(slug: string): string | null { + const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 }); + if (result.status !== 0) return null; + try { + const page = JSON.parse(result.stdout) as { body?: string; title?: string }; + if (!page?.body) return null; + return compressPage(slug, page.title || slug, page.body); + } catch { + return null; + } +} + +function fetchUserProfile(): string | null { + // The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept + // env GSTACK_USER_SLUG as override, fallback to $USER for direct calls. + const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown'; + return fetchSimplePage(`gstack/user-profile/${slug}`); +} + +function fetchProduct(projectSlug: string | null): string | null { + if (!projectSlug) return null; + return fetchSimplePage(`gstack/product/${projectSlug}`); +} + +/** + * Goals are LIST queries: all gstack/goal//* pages. + * Compress the top N by recency. + */ +function fetchGoals(projectSlug: string | null): string | null { + if (!projectSlug) return null; + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([ + 'list-pages', + '--type', 'gstack/goal', + '--limit', '10', + '--json', + ]); + if (!result?.pages) return null; + const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`)); + if (goals.length === 0) { + // Empty digest is valid (just header + 'no active goals' line) + return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`; + } + const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`); + return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`; +} + +/** + * recent-decisions: last 5 gstack/skill-run pages for this project, compressed + * to one-line summaries. + */ +function fetchRecentDecisions(projectSlug: string | null): string | null { + if (!projectSlug) return null; + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([ + 'list-pages', + '--type', 'gstack/skill-run', + '--limit', '5', + '--sort', 'updated_desc', + '--json', + ]); + if (!result?.pages) { + // F10 bug fix: this branch used to return the hardcoded + // "_No prior skill runs recorded._" string here, which is indistinguishable + // from a genuine zero-rows result. That silently converted a gbrain- + // unreachable FAILURE into a "successful" cached digest — refreshEntity() + // would write it and stamp last_refresh, so the false negative survived + // every subsequent TTL cycle forever. Returning null instead lets cmdGet's + // existing missing/stale-fallback machinery report the true state, exactly + // like every sibling fetcher (fetchGoals, fetchSimplePage) already does on + // failure. + return null; + } + // A malformed payload ({pages: {}} etc.) must classify as failure, not crash + // refreshEntity mid-refresh — same honest-missing polarity as the F10 fix. + if (!Array.isArray(result.pages)) return null; + if (result.pages.length === 0) { + return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`; + } + const lines = result.pages.map((p) => `- ${p.title || p.slug}`); + return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`; +} + +/** + * Reads the user's salience allowlist override from gstack-config. If unset, + * returns SALIENCE_DEFAULT_ALLOWLIST. The override is comma-separated; we + * trim and drop empty entries. + */ +export function getSalienceAllowlist(): ReadonlyArray { + // Short-circuit via env var for tests + headless callers. + const env = process.env.GSTACK_SALIENCE_ALLOWLIST; + if (typeof env === 'string' && env.length > 0) { + return env.split(',').map((s) => s.trim()).filter(Boolean); + } + // Shell out to gstack-config with a tight timeout. Falls back to defaults + // on any failure (config script missing, command non-zero, parse error). + try { + const skillRoot = join(homedir(), '.claude', 'skills', 'gstack'); + const bin = join(skillRoot, 'bin', 'gstack-config'); + if (!existsSync(bin)) return SALIENCE_DEFAULT_ALLOWLIST; + const result = spawnSync(bin, ['get', 'salience_allowlist'], { timeout: 2000, encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return SALIENCE_DEFAULT_ALLOWLIST; + const trimmed = result.stdout.trim(); + if (!trimmed) return SALIENCE_DEFAULT_ALLOWLIST; + const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean); + return parts.length > 0 ? parts : SALIENCE_DEFAULT_ALLOWLIST; + } catch { + return SALIENCE_DEFAULT_ALLOWLIST; + } +} + +/** + * D9 salience privacy gate: returns true if the slug starts with any allowlisted + * prefix. Anything NOT matching is stripped at digest write time so that family, + * therapy, reflection, and other sensitive content never leaks into work-flow + * planning prompts by default. + */ +export function isSalienceSlugAllowed(slug: string, allowlist: ReadonlyArray): boolean { + for (const prefix of allowlist) { + if (slug.startsWith(prefix)) return true; + } + return false; +} + +function fetchSalience(projectSlug: string | null): string | null { + // get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([ + 'get-recent-salience', + '--days', '14', + '--limit', '10', + '--json', + ]); + // F10 bug fix (sibling of fetchRecentDecisions above): a gbrain-unreachable + // failure used to render the identical hardcoded "no salient pages" string + // as a genuine empty result, which refreshEntity() then cached as if it + // were verified truth. Unlike recent-decisions there is no project-local + // fallback for salience — it is specifically gbrain's emotional-weight- + // ranked *brain* pages, not project decision/work data, and conflating the + // two would defeat the D9 privacy allowlist's purpose. So on failure we + // return null and let the cache report 'missing' (same as product.md, + // goals.md, etc. already do on this machine) instead of asserting a claim + // we have no way to verify. + if (!result?.pages) return null; + + // D9 privacy gate: strip entries outside the allowlist BEFORE rendering. + // Sensitive personal content (family, therapy, reflection) is never written + // into the digest cache file, even when the brain itself ranks it salient. + const allowlist = getSalienceAllowlist(); + const filtered = result.pages.filter((p) => p.slug && isSalienceSlugAllowed(p.slug, allowlist)); + const stripped = result.pages.length - filtered.length; + if (filtered.length === 0) { + const header = `# Recent salience (last 14d)`; + const note = stripped > 0 + ? `\n_All ${stripped} salient entries stripped by allowlist gate (no work-flow content in window)._\n` + : `\n_No salient pages in last 14d._\n`; + return `${header}\n${note}`; + } + const lines = filtered.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`); + const footer = stripped > 0 + ? `\n\n_${stripped} private entries stripped by allowlist gate._` + : ''; + return `# Recent salience (last 14d)\n\n${lines.join('\n')}${footer}\n`; +} + +/** + * Compress a brain page body into a digest. The compressor keeps frontmatter + * out, trims body to the first H2/H3 sections, and prepends a slug header. + * Per-entity budget enforcement happens at the caller (refreshEntity). + */ +function compressPage(slug: string, title: string, body: string): string { + const trimmed = body + .replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter + .trim(); + return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`; +} + +/** + * Truncate a digest to a byte budget. Tries to cut at the last newline before + * the budget so the digest stays readable. + */ +function truncateToBudget(content: string, budgetBytes: number): string { + const buf = Buffer.from(content, 'utf-8'); + if (buf.byteLength <= budgetBytes) return content; + const truncated = buf.slice(0, budgetBytes).toString('utf-8'); + const lastNewline = truncated.lastIndexOf('\n'); + const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated; + return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`; +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: digest +// ────────────────────────────────────────────────────────────────────────── + +/** + * Public: compress a brain page slug to digest format. Used by callers that + * want to know what the digest WOULD look like without writing to cache. + */ +export function cmdDigest(slug: string): string | null { + return fetchSimplePage(slug); +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: meta +// ────────────────────────────────────────────────────────────────────────── + +export function cmdMeta(projectSlug: string | null): CacheMeta { + if (projectSlug) return loadMeta('per-project', projectSlug); + return loadMeta('cross-project', null); +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: bootstrap (T2b) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Bootstrap synthesizes draft entity content from CLAUDE.md + README + + * recent commits + learnings.jsonl for a fresh project. Emits as JSON for + * the caller (skill template) to AUQ-confirm before any write to the brain. + * + * This keeps the CLI pure (no AUQ logic) while preventing silent + * auto-extraction garbage (D10 T4 fix). The agent is responsible for the + * "Synthesized X — looks right?" prompt per entity. + */ +export interface BootstrapDraft { + product?: { slug: string; title: string; body: string }; + goals?: Array<{ slug: string; title: string; body: string }>; + developer_persona?: { slug: string; title: string; body: string }; + brand?: { slug: string; title: string; body: string }; + competitive_intel?: { slug: string; title: string; body: string }; +} + +export function cmdBootstrap(projectSlug: string): BootstrapDraft { + const draft: BootstrapDraft = {}; + const repoRoot = process.env.GSTACK_REPO_ROOT || process.cwd(); + + // Product synthesis: CLAUDE.md headline + README first paragraph + let claudeMd = ''; + try { claudeMd = readFileSync(join(repoRoot, 'CLAUDE.md'), 'utf-8'); } catch { /* missing is fine */ } + let readmeMd = ''; + try { readmeMd = readFileSync(join(repoRoot, 'README.md'), 'utf-8'); } catch { /* missing is fine */ } + + const productLead = synthesizeProductLead(claudeMd, readmeMd, projectSlug); + if (productLead) { + draft.product = { + slug: `gstack/product/${projectSlug}`, + title: projectSlug, + body: productLead, + }; + } + + // Goals: try learnings.jsonl + recent commit messages mentioning "goal" or "ship" + const learningsPath = join(GSTACK_HOME, 'projects', projectSlug, 'learnings.jsonl'); + const goalsHints = synthesizeGoalsHints(learningsPath, repoRoot); + if (goalsHints.length > 0) { + draft.goals = goalsHints.slice(0, 3).map((hint, idx) => ({ + slug: `gstack/goal/${projectSlug}/bootstrap-${idx + 1}`, + title: hint.title, + body: hint.body, + })); + } + + return draft; +} + +function synthesizeProductLead(claudeMd: string, readmeMd: string, slug: string): string | null { + // First H1 in CLAUDE.md or README, plus first paragraph after it. + const source = claudeMd || readmeMd; + if (!source) return null; + const h1Match = source.match(/^#\s+(.+)$/m); + const heading = h1Match?.[1]?.trim() || slug; + // First non-heading paragraph + const paraMatch = source.match(/(?:^|\n)([^#\n][^\n]+(?:\n[^#\n][^\n]+)*)/); + const lead = paraMatch?.[1]?.trim() || '(no description found in CLAUDE.md or README)'; + return [ + `# ${heading}`, + '', + '## What', + lead.slice(0, 500), + '', + '## Stage', + '(fill in current stage, e.g., v1.x shipped, in development, paused)', + '', + '## Team', + '(fill in team composition + size)', + '', + '## Active goals', + '(populated by /office-hours over time)', + '', + '## Recent decisions', + '(populated by /plan-ceo-review over time)', + '', + ].join('\n'); +} + +function synthesizeGoalsHints(learningsPath: string, repoRoot: string): Array<{ title: string; body: string }> { + const hints: Array<{ title: string; body: string }> = []; + if (existsSync(learningsPath)) { + try { + const lines = readFileSync(learningsPath, 'utf-8').split('\n').filter(Boolean); + for (const line of lines.slice(-10)) { + try { + const entry = JSON.parse(line); + if (entry?.insight && (entry?.type === 'pattern' || entry?.type === 'architecture')) { + hints.push({ + title: entry.insight.slice(0, 80), + body: `Source: learnings.jsonl\nType: ${entry.type}\n\n${entry.insight}\n`, + }); + } + } catch { /* skip malformed line */ } + } + } catch { /* unreadable file, skip */ } + } + return hints; +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: list (T18) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Lists all gstack-owned pages currently in the brain for a project, grouped + * by type. Powers the user's ability to audit what gstack has written. + */ +export function cmdList(projectSlug: string | null): Array<{ type: string; slug: string; title?: string }> { + // We probe each gstack// namespace via list-pages with a type filter. + const types = ['gstack/user-profile', 'gstack/product', 'gstack/goal', 'gstack/developer-persona', 'gstack/brand', 'gstack/competitive-intel', 'gstack/skill-run', 'gstack/take']; + const all: Array<{ type: string; slug: string; title?: string }> = []; + for (const type of types) { + const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([ + 'list-pages', + '--type', type, + '--limit', '200', + '--json', + ]); + if (!result?.pages) continue; + for (const page of result.pages) { + if (projectSlug && !page.slug?.includes(`/${projectSlug}`) && type !== 'gstack/user-profile') { + continue; + } + all.push({ type, slug: page.slug, title: page.title }); + } + } + return all; +} + +// ────────────────────────────────────────────────────────────────────────── +// Subcommand: purge (T18) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Delete one gstack-owned page from the brain. Caller (skill template) is + * responsible for the confirm prompt; this is the raw operation. + */ +export function cmdPurge(slug: string): { deleted: boolean; error?: string } { + if (!slug.startsWith('gstack/')) { + return { deleted: false, error: 'refusing to purge non-gstack page' }; + } + const result = spawnGbrain(['delete-page', slug], { timeout: 10_000 }); + if (result.status !== 0) { + return { deleted: false, error: result.stderr?.trim() || `exit ${result.status}` }; + } + // Also invalidate any cached digests that referenced this page. + // Best-effort — derived digests may need explicit invalidate. + return { deleted: true }; +} + +// ────────────────────────────────────────────────────────────────────────── +// CLI dispatch +// ────────────────────────────────────────────────────────────────────────── + +function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record } { + const cmd = argv[2] || ''; + const rest = argv.slice(3); + const positional: string[] = []; + const flags: Record = {}; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const next = rest[i + 1]; + if (next && !next.startsWith('--')) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else { + positional.push(arg); + } + } + return { cmd, positional, flags }; +} + +function projectSlugFromFlag(flags: Record): string | null { + const v = flags.project; + return typeof v === 'string' ? v : null; +} + +function printUsage(): void { + process.stderr.write(`Usage: gstack-brain-cache + +Subcommands: + get [--project ] + refresh [--full] [--entity X] [--project ] + invalidate [--project ] + digest + meta [--project ] + bootstrap --project — emit synthesized entity drafts (JSON) + list [--project ] — list gstack-owned pages in brain + purge — delete a gstack-owned brain page (refuses non-gstack/ slugs) +`); +} + +async function main(): Promise { + const { cmd, positional, flags } = parseArgs(process.argv); + const projectSlug = projectSlugFromFlag(flags); + + try { + switch (cmd) { + case 'get': { + const entityName = positional[0]; + if (!entityName) { printUsage(); return 1; } + const result = cmdGet(entityName, projectSlug); + if (result.state === 'missing') { + process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`); + return 2; + } + if (result.state !== 'warm') { + process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`); + } + process.stdout.write(readFileSync(result.path, 'utf-8')); + return 0; + } + case 'refresh': { + // D3: dedup concurrent refreshes via lockfile. Skipped (dedup) when + // another process is already mid-refresh on the same project. + if (flags.entity) { + const entityName = String(flags.entity); + const result = withRefreshLock(projectSlug, () => refreshEntity(entityName, projectSlug)); + if (result === 'dedup') { + process.stderr.write(`(dedup: another refresh in flight)\n`); + return 3; + } + process.stdout.write(result ? `refreshed ${entityName}\n` : `failed to refresh ${entityName}\n`); + return result ? 0 : 1; + } + const allResult = withRefreshLock(projectSlug, () => refreshAll(projectSlug)); + if (allResult === 'dedup') { + process.stderr.write(`(dedup: another refresh in flight)\n`); + return 3; + } + process.stdout.write(`refreshed=${allResult.success} failed=${allResult.failed}\n`); + return allResult.failed > 0 ? 1 : 0; + } + case 'invalidate': { + const entityName = positional[0]; + if (!entityName) { printUsage(); return 1; } + cmdInvalidate(entityName, projectSlug); + process.stdout.write(`invalidated ${entityName}\n`); + return 0; + } + case 'digest': { + const slug = positional[0]; + if (!slug) { printUsage(); return 1; } + const content = cmdDigest(slug); + if (content === null) { + process.stderr.write('brain unreachable or page not found\n'); + return 2; + } + process.stdout.write(content); + return 0; + } + case 'meta': { + const meta = cmdMeta(projectSlug); + process.stdout.write(JSON.stringify(meta, null, 2) + '\n'); + return 0; + } + case 'bootstrap': { + if (!projectSlug) { + process.stderr.write('bootstrap requires --project \n'); + return 1; + } + const draft = cmdBootstrap(projectSlug); + process.stdout.write(JSON.stringify(draft, null, 2) + '\n'); + return 0; + } + case 'list': { + const pages = cmdList(projectSlug); + if (flags.json) { + process.stdout.write(JSON.stringify(pages, null, 2) + '\n'); + } else { + for (const p of pages) { + process.stdout.write(`${p.type}\t${p.slug}\t${p.title ?? ''}\n`); + } + } + return 0; + } + case 'purge': { + const slug = positional[0]; + if (!slug) { printUsage(); return 1; } + const result = cmdPurge(slug); + if (result.deleted) { + process.stdout.write(`deleted ${slug}\n`); + return 0; + } + process.stderr.write(`failed: ${result.error}\n`); + return 1; + } + case '': + case 'help': + case '--help': + case '-h': + printUsage(); + return 0; + default: + process.stderr.write(`unknown subcommand: ${cmd}\n`); + printUsage(); + return 1; + } + } catch (err) { + process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`); + return 1; + } +} + +// Only run main when invoked as a script (not when imported by tests) +if (import.meta.main) { + main().then((code) => process.exit(code)); +} diff --git a/.agents/skills/gstack/bin/gstack-brain-context-load.ts b/.agents/skills/gstack/bin/gstack-brain-context-load.ts new file mode 100644 index 0000000..9a8efe9 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-brain-context-load.ts @@ -0,0 +1,482 @@ +#!/usr/bin/env bun +/** + * gstack-brain-context-load — V1 retrieval surface (Lane C). + * + * Called from the gstack preamble at every skill start. Reads the active skill's + * `gbrain.context_queries:` frontmatter (Layer 2) or falls back to a generic + * salience block (Layer 1). Dispatches each query by kind: + * + * kind: vector → gbrain query + * kind: list → gbrain list_pages --filter ... + * kind: filesystem → local glob + * + * Each MCP/CLI call has a 500ms hard timeout per Section 1C. On timeout or + * "gbrain not in PATH" / "MCP not registered", the helper renders + * `(unavailable)` for that section and continues — skill startup never blocks + * > 2s on gbrain issues. + * + * Layer 1 fallback per F7 (Codex outside-voice): every default query carries + * an explicit `repo: {repo_slug}` filter so cross-repo contamination is the + * non-default path. + * + * Datamark envelope per Section 1D: each rendered page body is wrapped in + * `...` + * once at the page level (not per-message). Layer 1 prompt-injection defense. + * + * V1.5 P0: salience smarts promote to gbrain server-side MCP tools + * (`get_recent_salience`, `find_anomalies`). Helper signature stays the same; + * internals switch from 4-call composition to a single MCP call. + * + * Usage: + * gstack-brain-context-load --skill office-hours --repo garrytan-gstack + * gstack-brain-context-load --skill-file ./SKILL.md --repo X --user Y + * gstack-brain-context-load --window 14d --explain + * gstack-brain-context-load --quiet + */ + +import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs"; +import { join, dirname, basename, resolve, delimiter } from "path"; +import { spawnSync } from "child_process"; +import { homedir } from "os"; + +import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface CliArgs { + skill?: string; + skillFile?: string; + repo?: string; + user?: string; + branch?: string; + window: string; // e.g. "14d" + limit: number; + explain: boolean; + quiet: boolean; +} + +interface QueryResult { + query: GbrainManifestQuery; + ok: boolean; + rendered: string; + bytes: number; + duration_ms: number; + reason?: string; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const HOME = homedir(); +const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +// 500ms hard cap per Section 1C; overridable for slow/loaded environments +// (test harnesses under CI load, cold CLI starts). +const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500); +const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function printUsage(): void { + console.error(`Usage: gstack-brain-context-load [options] + +Options: + --skill Active skill name (looks up SKILL.md path) + --skill-file Direct path to SKILL.md (overrides --skill) + --repo Repo slug for {repo_slug} template var + --user User slug for {user_slug} template var + --branch Branch name for {branch} template var + --window Layer 1 window (default: 14d) + --limit Max results per query (default: from manifest, else 10) + --explain Print byte counts + which queries ran (to stderr) + --quiet Suppress everything except the rendered block + --help This text. + +Output: rendered ## sections to stdout, ready for the preamble to inject. +`); +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + let skill: string | undefined; + let skillFile: string | undefined; + let repo: string | undefined; + let user: string | undefined; + let branch: string | undefined; + let window = "14d"; + let limit = 10; + let explain = false; + let quiet = false; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + switch (a) { + case "--skill": skill = args[++i]; break; + case "--skill-file": skillFile = args[++i]; break; + case "--repo": repo = args[++i]; break; + case "--user": user = args[++i]; break; + case "--branch": branch = args[++i]; break; + case "--window": window = args[++i] || "14d"; break; + case "--limit": + limit = parseInt(args[++i] || "10", 10); + if (!Number.isFinite(limit) || limit <= 0) { + console.error("--limit requires a positive integer"); + process.exit(1); + } + break; + case "--explain": explain = true; break; + case "--quiet": quiet = true; break; + case "--help": + case "-h": + printUsage(); + process.exit(0); + default: + console.error(`Unknown argument: ${a}`); + printUsage(); + process.exit(1); + } + } + + return { skill, skillFile, repo, user, branch, window, limit, explain, quiet }; +} + +// ── Template var substitution ────────────────────────────────────────────── + +function substituteTemplateVars(s: string, args: CliArgs): { resolved: string; unresolved: string[] } { + const unresolved: string[] = []; + const resolved = s.replace(/\{(\w+)\}/g, (full, name) => { + switch (name) { + case "repo_slug": + if (args.repo) return args.repo; + unresolved.push(name); + return full; + case "user_slug": + if (args.user) return args.user; + unresolved.push(name); + return full; + case "branch": + if (args.branch) return args.branch; + unresolved.push(name); + return full; + case "skill_name": + if (args.skill) return args.skill; + unresolved.push(name); + return full; + case "window": + return args.window; + default: + unresolved.push(name); + return full; + } + }); + return { resolved, unresolved }; +} + +// ── Skill manifest resolution ────────────────────────────────────────────── + +function resolveSkillFile(args: CliArgs): string | null { + if (args.skillFile) { + return resolve(args.skillFile); + } + if (!args.skill) return null; + // Look in common gstack skill locations + const candidates = [ + join(HOME, ".claude", "skills", args.skill, "SKILL.md"), + join(HOME, ".claude", "skills", "gstack", args.skill, "SKILL.md"), + join(process.cwd(), ".claude", "skills", args.skill, "SKILL.md"), + join(process.cwd(), args.skill, "SKILL.md"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +// ── Dispatchers ──────────────────────────────────────────────────────────── + +let gbrainOnPath: boolean | null = null; + +function gbrainAvailable(): boolean { + // Stat-based PATH scan, memoized. Spawning `gbrain --version` under the + // 500ms budget misreported gbrain as missing whenever a cold process spawn + // exceeded the timeout (loaded machine, node-based CLI cold start), and + // re-probing per query burned 3x the budget before any real work. + if (gbrainOnPath !== null) return gbrainOnPath; + const exts = process.platform === "win32" + ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") + : [""]; + gbrainOnPath = (process.env.PATH || "").split(delimiter).some((dir) => + dir !== "" && exts.some((ext) => { + try { + accessSync(join(dir, `gbrain${ext}`), constants.X_OK); + return true; + } catch { + return false; + } + }) + ); + return gbrainOnPath; +} + +function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult { + const t0 = Date.now(); + const { resolved: query, unresolved } = substituteTemplateVars(q.query || "", args); + if (unresolved.length > 0) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: `template vars unresolved: ${unresolved.join(",")}`, + }; + } + if (!gbrainAvailable()) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "gbrain CLI missing" }; + } + + const limit = q.limit ?? args.limit; + const result = spawnSync("gbrain", ["query", query, "--limit", String(limit), "--format", "compact"], { + encoding: "utf-8", + timeout: MCP_TIMEOUT_MS, + }); + + if (result.status !== 0 || !result.stdout) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: result.error?.message || `gbrain query exited ${result.status}`, + }; + } + + const rendered = wrapDatamarked(q.render_as, capBody(result.stdout)); + return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; +} + +function dispatchList(q: GbrainManifestQuery, args: CliArgs): QueryResult { + const t0 = Date.now(); + if (!gbrainAvailable()) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "gbrain CLI missing" }; + } + const limit = q.limit ?? args.limit; + const cliArgs: string[] = ["list_pages", "--limit", String(limit)]; + if (q.sort) cliArgs.push("--sort", q.sort); + if (q.filter) { + for (const [k, v] of Object.entries(q.filter)) { + const { resolved: rv } = substituteTemplateVars(String(v), args); + cliArgs.push("--filter", `${k}=${rv}`); + } + } + const result = spawnSync("gbrain", cliArgs, { encoding: "utf-8", timeout: MCP_TIMEOUT_MS }); + if (result.status !== 0 || !result.stdout) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: result.error?.message || `gbrain list_pages exited ${result.status}`, + }; + } + const rendered = wrapDatamarked(q.render_as, capBody(result.stdout)); + return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; +} + +function dispatchFilesystem(q: GbrainManifestQuery, args: CliArgs): QueryResult { + const t0 = Date.now(); + if (!q.glob) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "filesystem kind missing glob" }; + } + const { resolved: glob, unresolved } = substituteTemplateVars(q.glob, args); + if (unresolved.length > 0) { + return { + query: q, + ok: false, + rendered: "", + bytes: 0, + duration_ms: Date.now() - t0, + reason: `template vars unresolved: ${unresolved.join(",")}`, + }; + } + // Expand ~ to home dir + const expanded = glob.replace(/^~/, HOME); + + // Simple glob: match against filesystem + const matches = simpleGlob(expanded); + if (matches.length === 0) { + return { query: q, ok: false, rendered: "", bytes: 0, duration_ms: Date.now() - t0, reason: "no matches" }; + } + + // Sort + limit + let sorted = matches; + if (q.sort === "mtime_desc") { + sorted = matches + .map((p) => ({ p, mtime: tryStatMtime(p) })) + .sort((a, b) => b.mtime - a.mtime) + .map((x) => x.p); + } + const limit = q.limit ?? args.limit; + const limited = q.tail !== undefined ? sorted.slice(-q.tail) : sorted.slice(0, limit); + + const lines = limited.map((p) => { + const mt = new Date(tryStatMtime(p)).toISOString().slice(0, 10); + return `- ${mt} — ${basename(p)}`; + }); + const rendered = wrapDatamarked(q.render_as, capBody(lines.join("\n"))); + return { query: q, ok: true, rendered, bytes: rendered.length, duration_ms: Date.now() - t0 }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function simpleGlob(pattern: string): string[] { + // Handle simple patterns: /** or /file or + if (!pattern.includes("*") && !pattern.includes("?")) { + return existsSync(pattern) ? [pattern] : []; + } + // Split on the last '/' before any glob char + const idx = pattern.search(/[*?]/); + const dirEnd = pattern.lastIndexOf("/", idx); + if (dirEnd === -1) return []; + const dir = pattern.slice(0, dirEnd); + const fileGlob = pattern.slice(dirEnd + 1); + if (!existsSync(dir)) return []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + const re = new RegExp("^" + fileGlob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); + return entries.filter((e) => re.test(e)).map((e) => join(dir, e)); +} + +function tryStatMtime(p: string): number { + try { + return statSync(p).mtimeMs; + } catch { + return 0; + } +} + +function capBody(s: string): string { + if (s.length <= PAGE_SIZE_CAP) return s; + return s.slice(0, PAGE_SIZE_CAP) + `\n\n_(truncated; ${s.length - PAGE_SIZE_CAP} more bytes — query gbrain directly for full results)_\n`; +} + +function wrapDatamarked(renderAs: string, body: string): string { + // Layer 1 prompt-injection defense (Section 1D, D12). Single envelope around + // the whole rendered body, not per-message. + return [ + renderAs, + "", + "", + body, + "", + "", + ].join("\n"); +} + +// ── Layer 1 fallback (no manifest) ───────────────────────────────────────── + +function defaultManifest(args: CliArgs): GbrainManifest { + // Per plan §"Three-section default" (D13). Each query carries explicit + // `repo: {repo_slug}` filter (F7 cleanup) so cross-repo contamination is + // the non-default path. + return { + schema: 1, + context_queries: [ + { + id: "recent-transcripts", + kind: "list", + filter: { type: "transcript", "tags_contains": "repo:{repo_slug}" }, + sort: "updated_at_desc", + limit: 5, + render_as: "## Recent transcripts in this repo", + }, + { + id: "recent-curated", + kind: "list", + filter: { "tags_contains": "repo:{repo_slug}", updated_after: "now-7d" }, + sort: "updated_at_desc", + limit: 10, + render_as: "## Recent curated memory", + }, + { + id: "skill-name-events", + kind: "list", + filter: { type: "timeline", content_contains: "{skill_name}" }, + limit: 5, + render_as: "## Recent {skill_name} events", + }, + ], + }; +} + +// ── Main pipeline ────────────────────────────────────────────────────────── + +async function loadContext(args: CliArgs): Promise<{ rendered: string; results: QueryResult[]; mode: "manifest" | "default" }> { + const skillFile = resolveSkillFile(args); + let manifest: GbrainManifest | null = null; + let mode: "manifest" | "default" = "default"; + + if (skillFile) { + manifest = parseSkillManifest(skillFile); + if (manifest && manifest.context_queries.length > 0) { + mode = "manifest"; + } + } + if (!manifest) { + manifest = defaultManifest(args); + } + + const results: QueryResult[] = []; + for (const q of manifest.context_queries) { + const r = await withErrorContext(`context-load:${q.id}`, () => { + switch (q.kind) { + case "vector": return dispatchVector(q, args); + case "list": return dispatchList(q, args); + case "filesystem": return dispatchFilesystem(q, args); + } + }, "gstack-brain-context-load"); + results.push(r); + } + + // Substitute render_as template vars (e.g. "{skill_name}") + const rendered = results + .filter((r) => r.ok && r.rendered.length > 0) + .map((r) => { + const { resolved } = substituteTemplateVars(r.rendered, args); + return resolved; + }) + .join("\n"); + + return { rendered, results, mode }; +} + +// ── Entry point ──────────────────────────────────────────────────────────── + +async function main(): Promise { + const args = parseArgs(); + const { rendered, results, mode } = await loadContext(args); + + if (!args.quiet && rendered.length > 0) { + console.log(rendered); + } + + if (args.explain) { + console.error(`[brain-context-load] mode=${mode} queries=${results.length}`); + for (const r of results) { + const status = r.ok ? "OK" : "SKIP"; + console.error(` ${status.padEnd(5)} ${r.query.id.padEnd(28)} kind=${r.query.kind.padEnd(10)} bytes=${r.bytes.toString().padStart(6)} dur=${r.duration_ms}ms${r.reason ? ` (${r.reason})` : ""}`); + } + const totalBytes = results.reduce((s, r) => s + r.bytes, 0); + const totalDur = results.reduce((s, r) => s + r.duration_ms, 0); + console.error(`[brain-context-load] total bytes=${totalBytes} dur=${totalDur}ms`); + } +} + +main().catch((err) => { + console.error(`gstack-brain-context-load fatal: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/.agents/skills/gstack/bin/gstack-brain-enqueue b/.agents/skills/gstack/bin/gstack-brain-enqueue new file mode 100755 index 0000000..815eff3 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-brain-enqueue @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# gstack-brain-enqueue — write a path record into the GBrain sync spool. +# +# Usage: +# gstack-brain-enqueue +# +# Called by writer scripts (gstack-learnings-log, gstack-timeline-log, etc.) +# after their local write. Fire-and-forget; failures are silent (never blocks +# the writer). The spool is drained by `gstack-brain-sync --once` invoked from +# the preamble at skill START and END boundaries. +# +# No-op when: +# - artifacts_sync_mode is off (the default) +# - ~/.gstack/.git doesn't exist (feature not initialized) +# - matches a line in ~/.gstack/.brain-skip.txt +# +# Env: +# GSTACK_HOME — override ~/.gstack state directory (aligns with writers). +# Tests use GSTACK_HOME=/tmp/test-$$ for isolation. +# +# Concurrency: maildir-style spool — one FILE per record under +# .brain-queue.d/, created via tmp-file + atomic rename. Writer and drainer +# never share an inode, so there is no append/rewrite race by construction +# (the legacy single-file .brain-queue.jsonl append could race the drain's +# rewrite). Filenames are --.json, so a sorted listing is +# chronological. + +# No `-e` — writer shims rely on this never failing loudly. +set -uo pipefail + +FILE="${1:-}" +[ -z "$FILE" ] && exit 0 + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SPOOL="$GSTACK_HOME/.brain-queue.d" +SKIP_FILE="$GSTACK_HOME/.brain-skip.txt" + +# Fast exits: no git repo, no sync. +[ ! -d "$GSTACK_HOME/.git" ] && exit 0 + +# Check sync mode. off → silent no-op. +SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" +MODE=$("$SCRIPT_DIR/gstack-config" get artifacts_sync_mode 2>/dev/null || echo off) +[ "$MODE" = "off" ] && exit 0 + +# User-maintained skip list (for secret-scan false positives). +if [ -f "$SKIP_FILE" ]; then + if grep -Fxq "$FILE" "$SKIP_FILE" 2>/dev/null; then + exit 0 + fi +fi + +# JSON-escape the file path (backslash + quotes only; paths shouldn't have other specials). +ESC_FILE=$(printf '%s' "$FILE" | sed 's/\\/\\\\/g; s/"/\\"/g') +TS=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") + +# One spool file per record: tmp write + atomic rename. Any failure exits 0 +# silently (fire-and-forget contract), cleaning up the tmp file. +mkdir -p "$SPOOL" 2>/dev/null || exit 0 +TMP="$SPOOL/.tmp-$$-$RANDOM" +printf '{"file":"%s","ts":"%s"}\n' "$ESC_FILE" "$TS" > "$TMP" 2>/dev/null || { rm -f "$TMP" 2>/dev/null; exit 0; } +mv -f "$TMP" "$SPOOL/$(date +%s)-$$-$RANDOM.json" 2>/dev/null || rm -f "$TMP" 2>/dev/null + +exit 0 diff --git a/.agents/skills/gstack/bin/gstack-brain-restore b/.agents/skills/gstack/bin/gstack-brain-restore new file mode 100755 index 0000000..781ba70 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-brain-restore @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# gstack-brain-restore — bootstrap a new machine from an existing brain repo. +# +# Usage: +# gstack-brain-restore [] +# +# If no URL is given, reads from ~/.gstack-brain-remote.txt (written by +# gstack-brain-init on the original machine). Copy that file to the new +# machine before running this command. +# +# Safety gates (refuses with clear message): +# - ~/.gstack/.git already exists with a DIFFERENT remote +# - ~/.gstack/ contains non-allowlisted, non-gitignored user files +# that would be clobbered by restore +# +# What it does: +# 1. Clone the remote to a staging directory +# 2. Validate the repo is gstack-brain-shaped (.brain-allowlist, .gitattributes) +# 3. rsync-copy tracked files into ~/.gstack/ with skip-if-same-hash +# 4. Move staging's .git into ~/.gstack/.git +# 5. Register local git config merge drivers (they don't clone from remote) +# 6. Wire the cloned brain into gbrain via gstack-gbrain-source-wireup +# (best-effort; restore continues even if gbrain wireup fails) +# +# Env: +# GSTACK_HOME — override ~/.gstack + +# 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 -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" + +# Egress receipt helpers (_receipted_git): fail-open for user-directed +# git ops against the user's own artifacts remote. +. "$SCRIPT_DIR/gstack-egress-lib.sh" +# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during the +# migration window. The migration script renames the file in place. +if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then + REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" +else + REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +fi + +REMOTE_URL="${1:-}" +if [ -z "$REMOTE_URL" ]; then + if [ -f "$REMOTE_FILE" ]; then + REMOTE_URL=$(head -1 "$REMOTE_FILE" | tr -d '[:space:]') + fi +fi + +if [ -z "$REMOTE_URL" ]; then + cat >&2 < + or put the URL in $REMOTE_FILE (copy from the original machine) +EOF + exit 1 +fi + +# ---- safety gates ---- +if [ -d "$GSTACK_HOME/.git" ]; then + EXISTING_REMOTE=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + if [ -n "$EXISTING_REMOTE" ] && [ "$EXISTING_REMOTE" != "$REMOTE_URL" ]; then + cat >&2 </dev/null' EXIT + +echo "Cloning $REMOTE_URL to staging..." +RESTORE_HOST="${REMOTE_URL#*://}"; RESTORE_HOST="${RESTORE_HOST#*@}"; RESTORE_HOST="${RESTORE_HOST%%[/:]*}" +if ! _receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-clone "user ran gstack-brain-restore" \ + bash -c 'git clone --quiet "$1" "$2" 2>/dev/null' _ "$REMOTE_URL" "$STAGING/repo"; then + echo "Clone failed. Check:" >&2 + echo " - URL is correct: $REMOTE_URL" >&2 + echo " - Auth: gh auth status (github) / glab auth status (gitlab)" >&2 + exit 1 +fi + +# ---- validate shape ---- +if [ ! -f "$STAGING/repo/.brain-allowlist" ] || [ ! -f "$STAGING/repo/.gitattributes" ]; then + cat >&2 < 5: + print(f"...and {len(risks) - 5} more") +sys.exit(0 if not risks else 2) +PYEOF + ) || true + if [ -n "$CLOBBER_RISK" ]; then + cat >&2 </dev/null 2>&1' _ "$GSTACK_HOME" || true +else + mv "$STAGING/repo/.git" "$GSTACK_HOME/.git" +fi + +# ---- register merge drivers (local git config; don't survive clones) ---- +git -C "$GSTACK_HOME" config merge.jsonl-append.driver "$SCRIPT_DIR/gstack-jsonl-merge %O %A %B" +git -C "$GSTACK_HOME" config merge.jsonl-append.name "gstack JSONL append-only merger" +git -C "$GSTACK_HOME" config merge.union.driver "cat %A %B > %A.merged && mv %A.merged %A" +git -C "$GSTACK_HOME" config merge.union.name "union concat" + +# ---- install pre-commit hook (same as init) ---- +HOOK="$GSTACK_HOME/.git/hooks/pre-commit" +mkdir -p "$(dirname "$HOOK")" +cat > "$HOOK" <<'HOOK_EOF' +#!/usr/bin/env bash +set -uo pipefail +python3 -c " +import sys, re, subprocess +try: + out = subprocess.check_output(['git', 'diff', '--cached'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace') +except Exception: + sys.exit(0) +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', + re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\s*:\s*\"[A-Za-z0-9_./+=-]{16,}\"', + re.IGNORECASE)), +] +for name, rx in patterns: + if rx.search(out): + sys.stderr.write(f'gstack-brain pre-commit: refusing commit — {name} detected.\n') + sys.exit(1) +sys.exit(0) +" +HOOK_EOF +chmod +x "$HOOK" + +# ---- write remote helper file if missing ---- +if [ ! -f "$REMOTE_FILE" ]; then + echo "$REMOTE_URL" > "$REMOTE_FILE" + chmod 600 "$REMOTE_FILE" + echo "" + echo "Wrote $REMOTE_FILE for future skill-run auto-detection." +fi + +# ---- wire the cloned brain into gbrain (best-effort) ---- +WIREUP_BIN="$SCRIPT_DIR/gstack-gbrain-source-wireup" +if [ -x "$WIREUP_BIN" ]; then + "$WIREUP_BIN" || >&2 echo "WARNING: gbrain wireup failed; run $WIREUP_BIN manually after fixing prereqs" +fi + +cat < add

to ~/.gstack/.brain-skip.txt +# gstack-brain-sync --drop-queue --yes clear queue without committing +# gstack-brain-sync --discover-new scan allowlist dirs, enqueue changed files +# +# Invoked by the preamble at skill START and END boundaries. No persistent +# daemon. Typical run <1s when queue empty; ~200-800ms with network push. +# +# Singleton enforcement: flock on ~/.gstack/.brain-sync.lock. Concurrent +# invocations queue and serialize. +# +# 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 + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +# Maildir-style spool: one FILE per record, --.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" +LOCK_FILE="$GSTACK_HOME/.brain-sync.lock" +DISCOVER_CURSOR="$GSTACK_HOME/.brain-discover-cursor" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" + +# Egress receipt helpers (_receipted_git): receipt-before-send, fail-closed. +. "$SCRIPT_DIR/gstack-egress-lib.sh" + +# 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 +} + +write_status() { + # args: status_code message [extra_json_blob] + local code="$1" + local msg="$2" + local extra="${3:-{\}}" + local ts + ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") + python3 - "$STATUS_FILE" "$code" "$msg" "$ts" "$extra" <<'PYEOF' 2>/dev/null || true +import json, sys +path, code, msg, ts, extra = sys.argv[1:6] +try: + extra_obj = json.loads(extra) if extra else {} +except Exception: + extra_obj = {} +data = {"status": code, "message": msg, "ts": ts, **extra_obj} +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 regex families — stdin scan. Exits 0 clean, 1 if hit. +# Echoes the matching pattern family name on hit. Uses python3 -c (not +# heredoc) so sys.stdin stays available for the diff content. +secret_scan_stdin() { + python3 -c " +import sys, re +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)), +] +text = sys.stdin.read() +for name, rx in patterns: + m = rx.search(text) + if m: + snippet = m.group(0) + if len(snippet) > 30: + snippet = snippet[:30] + '...' + print(name + ':' + snippet) + sys.exit(1) +sys.exit(0) +" +} + +# 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. +compute_paths_to_stage() { + local mode="$1" + local class_file="${2:-}" + local snapshot_file="${3:-}" + python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" <<'PYEOF' +import sys, json, os, fnmatch, glob + +gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file = sys.argv[1:9] + +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 + +final = [] +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): + if p in skip_lines: + 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 + # 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 + final.append(p) + +if class_file: + with open(class_file, "w") as f: + json.dump(classified, f) + +for p in final: + print(p) +PYEOF +} + +# 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 + +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: + continue # retained / unclassified: stays queued (explicit-delete-only) + try: + os.unlink(full) # staged or dropped: fully processed + except FileNotFoundError: + 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 +} + +subcmd_once() { + if ! sync_active; then + # Silent no-op when feature not initialized / disabled. + exit 0 + fi + + # Singleton lock via atomic mkdir. `flock(1)` isn't on macOS by default; + # `mkdir` is atomic on every POSIX filesystem. If another --once is already + # running, skip (don't wait) — the next skill boundary will catch up. + local lock_dir="${LOCK_FILE}.d" + if ! mkdir "$lock_dir" 2>/dev/null; then + # Is the lock stale? Check the pidfile inside. If process is dead, clear it. + if [ -f "$lock_dir/pid" ]; then + local lock_pid + lock_pid=$(cat "$lock_dir/pid" 2>/dev/null || echo "") + if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then + # Stale lock — clear and retry once. + rm -rf "$lock_dir" 2>/dev/null || true + if ! mkdir "$lock_dir" 2>/dev/null; then + exit 0 + fi + else + # Lock is held by a live process. + exit 0 + fi + else + # Lock dir without pidfile — treat as held; don't touch. + exit 0 + fi + fi + echo "$$" > "$lock_dir/pid" 2>/dev/null || true + # Release the lock on EVERY exit from here on — including the empty-queue + # fast path and an INT during the detector's network push. Leaking it would + # rely on next-run stale-pid detection, which PID reuse can defeat (kill -0 + # matching an unrelated live process wedges sync at every boundary). The + # mktemp block below re-traps with tempfile cleanup added; both traps keep + # the lock removal. + trap 'rm -rf "$lock_dir" 2>/dev/null || true' 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 + + # #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/ 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 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 ]; 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" + 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 + write_status "idle" "queue empty" + exit 0 + fi + + local paths_file class_file snapshot_file + paths_file=$(mktemp /tmp/brain-sync-paths.XXXXXX) || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } + class_file=$(mktemp /tmp/brain-sync-class.XXXXXX) || { rm -f "$paths_file"; rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } + snapshot_file=$(mktemp /tmp/brain-sync-snapshot.XXXXXX) || { rm -f "$paths_file" "$class_file"; rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } + # Single trap covers all: lock cleanup AND tempfile cleanup. + trap 'rm -f "$paths_file" "$class_file" "$snapshot_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' 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. + if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_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 + + # Stage with git add -f (forces past .gitignore=*) explicit paths only. + while IFS= read -r p; do + p="${p%$'\r'}" # Windows: compute_paths_to_stage's python print() emits CRLF; + # a trailing CR makes the pathspec match nothing (silent no-stage). + [ -z "$p" ] && continue + git -C "$GSTACK_HOME" add -f -- "$p" 2>/dev/null || true + done < "$paths_file" + + # Secret-scan staged diff. + local scan_out + scan_out=$(git -C "$GSTACK_HOME" diff --cached 2>/dev/null | secret_scan_stdin || true) + if [ -n "$scan_out" ]; then + # Hit — unstage, preserve queue, write loud status. + git -C "$GSTACK_HOME" reset HEAD -- . >/dev/null 2>&1 || true + local hint + hint="secret pattern detected ($scan_out). Remediation: review the staged file, then run: gstack-brain-sync --skip-file OR edit the content." + write_status "blocked" "$hint" + echo "BRAIN_SYNC: blocked: $scan_out" >&2 + 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. + local n ts + n=$(wc -l < "$paths_file" | tr -d ' ') + ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) + local msg="sync: $n file(s) | $ts" + git -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \ + commit -q -m "$msg" 2>/dev/null || { + # Nothing to commit (e.g. all files already committed). The drained + # records leave the spool; retained + post-snapshot records survive. + finalize_queue "$snapshot_file" "$class_file" "$paths_file" + write_status "idle" "queue drained but no new changes to commit" + 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; 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" "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" "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 + spool_depth=$(ls "$QUEUE_DIR"/*.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: gstack-brain-sync --skip-file " >&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 "(future writers will not enqueue this path; existing queue entries ignored on next --once)" +} + +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:-}" ;; + --drop-queue) shift; subcmd_drop_queue "${1:-}" ;; + --discover-new) subcmd_discover_new ;; + --help|-h) + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' + ;; + *) + echo "Unknown subcommand: $1" >&2 + echo "Run: gstack-brain-sync --help" >&2 + exit 1 + ;; +esac diff --git a/.agents/skills/gstack/bin/gstack-brain-uninstall b/.agents/skills/gstack/bin/gstack-brain-uninstall new file mode 100755 index 0000000..a240a85 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-brain-uninstall @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# gstack-brain-uninstall — clean off-ramp for gstack-brain sync. +# +# Usage: +# gstack-brain-uninstall [--yes] [--delete-remote] +# +# Removes the git layer from ~/.gstack/ and clears sync config. Your local +# gstack memory (learnings, timelines, etc.) is NOT touched — this is an +# uninstall-sync command, not a delete-data command. +# +# Flags: +# --yes Skip the confirmation prompt. +# --delete-remote Also delete the GitHub repo via `gh repo delete` +# (interactive unless --yes is also passed). +# +# What it removes (in ~/.gstack/): +# .git/ — the sync repo's git data +# .gitignore — canonical ignore-all marker +# .gitattributes — merge driver declarations +# .brain-allowlist — sync path list +# .brain-privacy-map.json — sync privacy classifier +# .brain-queue.d/ — pending spool (one file per record) +# .brain-queue.jsonl — legacy pending queue (pre-spool) +# .brain-discover-cursor — discover-new cursor +# .brain-last-push — timestamp marker +# .brain-worktree-last-advance — daily worktree-advance stamp (#2516) +# .brain-skip.txt — user-maintained skip list +# .brain-sync.lock.d/ — lock dir (if present) +# .brain-sync-status.json — health status +# consumers.json — consumer/reader registry +# +# What it clears (via gstack-config): +# artifacts_sync_mode → off +# artifacts_sync_mode_prompted → false (so user re-prompts on re-init) +# +# What it does NOT touch: +# Project data (projects/*, retros/*, developer-profile.json, etc.) +# Consumer tokens in gstack-config (_token keys) +# ~/.gstack-brain-remote.txt in your home directory +# The actual remote git repo (unless --delete-remote) + +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" +# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. +if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then + REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" +else + REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +fi + +ASSUME_YES=0 +DELETE_REMOTE=0 +while [ $# -gt 0 ]; do + case "$1" in + --yes|-y) ASSUME_YES=1; shift ;; + --delete-remote) DELETE_REMOTE=1; shift ;; + --help|-h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +if [ ! -d "$GSTACK_HOME/.git" ]; then + echo "gstack-brain-uninstall: nothing to do (~/.gstack/.git doesn't exist)." + exit 0 +fi + +REMOTE_URL=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + +# ---- confirmation ---- +if [ "$ASSUME_YES" != "1" ]; then + cat </dev/null 2>&1; then + # Extract owner/repo from URL. + REPO_SLUG=$(echo "$REMOTE_URL" | sed -E 's#.*[:/]([^/:]+/[^/]+)(\.git)?$#\1#' | sed 's/\.git$//') + if [ -n "$REPO_SLUG" ]; then + echo "Deleting GitHub repo: $REPO_SLUG" + if [ "$ASSUME_YES" = "1" ]; then + gh repo delete "$REPO_SLUG" --yes 2>/dev/null || echo "gh repo delete failed; continuing local uninstall" + else + gh repo delete "$REPO_SLUG" 2>/dev/null || echo "gh repo delete failed; continuing local uninstall" + fi + fi + else + echo "--delete-remote requires the gh CLI. Skipping remote deletion." + fi + ;; + *) + echo "--delete-remote only supports github.com remotes. Delete manually if needed: $REMOTE_URL" + ;; + esac +fi + +# ---- remove sync files ---- +echo "Removing git layer and sync config files..." +rm -rf "$GSTACK_HOME/.git" 2>/dev/null || true +rm -f "$GSTACK_HOME/.gitignore" 2>/dev/null || true +rm -f "$GSTACK_HOME/.gitattributes" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-allowlist" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-privacy-map.json" 2>/dev/null || true +rm -rf "$GSTACK_HOME/.brain-queue.d" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-queue.jsonl" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-queue.jsonl.migrating" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-discover-cursor" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-last-push" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-last-pull" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-worktree-last-advance" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-skip.txt" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-sync-status.json" 2>/dev/null || true +rm -rf "$GSTACK_HOME/.brain-sync.lock.d" 2>/dev/null || true + +# ---- unregister gbrain federated source + remove worktree (best-effort) ---- +# The wireup helper handles: gbrain sources remove, git worktree remove, +# launchd plist (future). All best-effort; uninstall continues on failure. +WIREUP_BIN="$SCRIPT_DIR/gstack-gbrain-source-wireup" +if [ -x "$WIREUP_BIN" ]; then + "$WIREUP_BIN" --uninstall 2>/dev/null || true +fi + +# ---- legacy consumers.json (no longer written by gstack-brain-init since v1.17.0.0) ---- +rm -f "$GSTACK_HOME/consumers.json" 2>/dev/null || true + +# ---- clear config keys ---- +"$CONFIG_BIN" set artifacts_sync_mode off >/dev/null 2>&1 || true +"$CONFIG_BIN" set artifacts_sync_mode_prompted false >/dev/null 2>&1 || true + +# ---- leave remote-helper file alone unless user asked to delete remote ---- +if [ "$DELETE_REMOTE" = "1" ]; then + rm -f "$REMOTE_FILE" 2>/dev/null || true +else + if [ -f "$REMOTE_FILE" ]; then + echo "(keeping $REMOTE_FILE — remove manually if you want to forget the URL)" + fi +fi + +cat < # record per-repo indexing consent (value REQUIRED) + * gstack-code-intelligence select + * gstack-code-intelligence index [repo-path] # index the repo with the selected provider + * gstack-code-intelligence search # search via the selected provider + * + * Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index + * until you consent for that repo. Graphify and a localhost Sourcebot are local: + * nothing leaves the machine, so no consent is needed. Graphify is never + * auto-installed. + */ + +import { createHash } from "crypto"; +import { realpathSync } from "fs"; +import { hostname } from "os"; +import { basename, resolve } from "path"; +import { + CodeProviderError, + RECOMMENDED_ORDER, + detectAvailable, + getRoot, + hasConsent, + providerById, + readSelection, + resolveSelectedProvider, + setConsent, + setProvider, + setRoot, + shouldOfferIndexing, + type CodeProviderId, +} from "../lib/code-intelligence"; + +const PROVIDER_IDS = new Set(["gbrain", "sourcebot", "graphify"]); +const LABEL: Record = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" }; +const NOTE: Record = { + gbrain: "recommended; federated memory + code (sends content to your GBrain DB)", + sourcebot: "self-hosted whole-repo regex search (local when on localhost)", + graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)", +}; + +function out(s: string): void { + process.stdout.write(`${s}\n`); +} +function fail(s: string): never { + process.stderr.write(`gstack-code-intelligence: ${s}\n`); + process.exit(1); +} + +async function cmdOptions(): Promise { + out("Code-intelligence providers (indexing is optional; GBrain recommended):\n"); + const avail = await detectAvailable(); + const byId = new Map(avail.map((a) => [a.id, a])); + for (const id of RECOMMENDED_ORDER) { + const a = byId.get(id); + const mark = a?.available ? "available" : "not available"; + out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`); + if (a?.detail) out(` ${a.detail}`); + } + out("\nSelect one with: gstack-code-intelligence select "); +} + +async function cmdStatus(): Promise { + const sel = readSelection(); + out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`); + const avail = await detectAvailable(); + for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`); +} + +/** + * The one-time session-start offer gate. Prints (or emits as JSON) whether an + * agent should ask the user about indexing this repo, and when it should, the + * provider options with their reasons so the question is self-contained. + */ +async function cmdSuggest(rest: string[]): Promise { + const json = rest.includes("--json"); + const pathArg = rest.find((a) => !a.startsWith("--")); + const repoPath = resolve(pathArg ?? process.cwd()); + const suggestion = shouldOfferIndexing(repoPath); + if (!suggestion.offer) { + if (json) { + out(JSON.stringify({ ...suggestion, repoPath })); + } else { + out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`); + } + return; + } + const avail = await detectAvailable(); + if (json) { + out(JSON.stringify({ + ...suggestion, + repoPath, + options: avail.map((a) => ({ + id: a.id, + label: LABEL[a.id], + reason: NOTE[a.id], + local: providerById(a.id).local, + available: a.available, + detail: a.detail, + })), + })); + return; + } + out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`); + await cmdOptions(); +} + +function cmdSelect(arg: string | undefined): void { + if (arg === "none") { + setProvider(null); + out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again"); + return; + } + if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) { + fail("Usage: select "); + } + const id = arg as CodeProviderId; + setProvider(id); + out(`selected ${LABEL[id]}.`); + const provider = providerById(id); + if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`); +} + +/** + * Record per-repo indexing consent: `consent [repo-path] `. + * + * The yes|no value is REQUIRED (true/false also accepted). It is never + * defaulted: an agent recording a user's "no" must persist consent DENIED, + * and a missing/unknown value must record NOTHING — a consent gate that + * assumes "yes" is a consent gate that lies. + */ +function cmdConsent(rest: string[]): void { + const positional = rest.filter((a) => !a.startsWith("--")); + const CONSENT_USAGE = "Usage: consent [repo-path] — the yes/no value is required; consent is never assumed"; + if (positional.length < 1 || positional.length > 2) fail(CONSENT_USAGE); + const value = positional[positional.length - 1].toLowerCase(); + let consented: boolean; + if (value === "yes" || value === "true") consented = true; + else if (value === "no" || value === "false") consented = false; + else fail(CONSENT_USAGE); + const repoPath = resolve(positional.length === 2 ? positional[0] : process.cwd()); + setConsent(repoPath, consented); + out(consented ? `indexing consent recorded for ${repoPath}` : `indexing consent DENIED for ${repoPath} (recorded)`); +} + +/** + * Host+path-hashed source id for GBrain/Sourcebot — the same approach as + * deriveCodeSourceId in bin/gstack-gbrain-sync.ts. A bare basename collides: + * two repos both named "api" (or the same repo on two machines against a + * federated brain) would silently share one source. Suffix = first 8 hex of + * sha1(`${hostname}::${realpath}`); base sanitized to gbrain's source-id + * charset (lowercase alnum + interior hyphens) and capped so the whole id + * stays within gbrain's 32-char limit. + */ +function hashedSourceId(repoPath: string): string { + let real = repoPath; + try { + real = realpathSync(repoPath); + } catch { + // path may not exist yet at id-derivation time — hash the resolved form + } + const host = process.env.GSTACK_HOSTNAME || hostname(); + const suffix = createHash("sha1").update(`${host}::${real}`).digest("hex").slice(0, 8); + const base = + basename(real) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 23) + .replace(/-+$/, "") || "repo"; + return `${base}-${suffix}`; +} + +async function cmdIndex(pathArg: string | undefined): Promise { + const provider = resolveSelectedProvider(); + if (!provider) fail("no provider selected; run `select ` first"); + const repoPath = resolve(pathArg ?? process.cwd()); + // Indexing is write-class: hasConsent's default op class applies, so a + // `deny` OR `read-only` repo trust policy vetoes it (code indexing writes + // pages — same semantics as gstack-gbrain-sync's runCodeImport). + const consented = hasConsent(repoPath); + if (!provider!.local && !consented) { + const recorded = readSelection().consents[repoPath] === true; + fail(recorded + ? `${provider!.label} indexing is blocked by the repo trust policy (deny or read-only — code indexing writes pages). Change with: gstack-gbrain-repo-policy set read-write` + : `${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath} yes\` first.`); + } + // Graphify keys sources on the repo path; GBrain/Sourcebot on a short + // host+path-hashed id (bare basenames collide across same-named repos). + const sourceId = provider!.id === "graphify" ? repoPath : hashedSourceId(repoPath); + const repo = { id: sourceId, path: repoPath }; + try { + const registered = await provider!.registerSource(repo, { consented }); + out(`registered ${repo.id} with ${provider!.label} (${registered.state})`); + const refreshed = await provider!.refresh({ id: registered.id }, { consented }); + // Remember which repo this provider indexed so `search` reads the same graph. + setRoot(provider!.id, repoPath); + out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`); + } catch (err) { + handleProviderError(err, provider!.label); + } +} + +async function cmdSearch(terms: string[]): Promise { + const query = terms.join(" ").trim(); + if (!query) fail("Usage: search "); + const provider = resolveSelectedProvider(); + if (!provider) fail("no provider selected; run `select ` first (or use grep)"); + // Search is read-class: a read-only repo trust policy still allows it + // (mirrors gstack-gbrain-sync: search allowed, page writes never), but a + // deny tier — or no recorded consent at all — still refuses for non-local + // providers, because the query text itself is repo-derived content. The + // consent repo is the one this provider indexed (search reads that graph); + // loopback providers need no consent, so their path is unchanged. + const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd()); + const consented = hasConsent(searchRoot, undefined, "read"); + // Honest pre-flight (mirrors cmdIndex): the adapter enforces the same gate + // (assertEgressConsent throws PROVIDER_NOT_CONSENTED before any bytes or + // receipt exist), but the CLI names WHY — missing consent vs a deny repo + // trust policy — instead of surfacing a generic provider error. + if (!provider!.local && !consented) { + const recorded = readSelection().consents[searchRoot] === true; + fail(recorded + ? `${provider!.label} search is blocked by the repo trust policy (deny — the query text is repo-derived content). Change with: gstack-gbrain-repo-policy set read-only (search allowed) or read-write` + : `${provider!.label} would send the query text (repo-derived content) off this machine. Run \`gstack-code-intelligence consent ${searchRoot} yes\` first.`); + } + try { + const hits = await provider!.search(query, { limit: 10, consented }); + if (!hits.length) { + out("(no results)"); + return; + } + for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`); + } catch (err) { + handleProviderError(err, provider!.label); + } +} + +function handleProviderError(err: unknown, label: string): never { + if (err instanceof CodeProviderError) { + if (err.code === "PROVIDER_UNAVAILABLE") { + fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`); + } + if (err.code === "PROVIDER_NOT_CONSENTED") { + fail(`${label} ${err.code}: ${err.message} Run \`gstack-code-intelligence consent yes\` first (a deny repo trust policy overrides recorded consent).`); + } + fail(`${label} ${err.code}: ${err.message}`); + } + fail(err instanceof Error ? err.message : String(err)); +} + +async function main(): Promise { + const [action, ...rest] = process.argv.slice(2); + switch (action) { + case "suggest": + return cmdSuggest(rest); + case "options": + return cmdOptions(); + case "status": + return cmdStatus(); + case "select": + return cmdSelect(rest[0]); + case "consent": + return cmdConsent(rest); + case "index": + return cmdIndex(rest[0]); + case "search": + return cmdSearch(rest); + default: + fail("Usage: suggest [path] [--json] | options | status | select | consent [path] | index [path] | search "); + } +} + +main().catch((err) => fail(err instanceof Error ? err.message : String(err))); diff --git a/.agents/skills/gstack/bin/gstack-codex-probe b/.agents/skills/gstack/bin/gstack-codex-probe new file mode 100755 index 0000000..2d151ef --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-codex-probe @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# gstack-codex-probe: shared helper for /codex and /autoplan skills. +# Sourced from template bash blocks; never execute directly. +# +# Functions (all prefixed with _gstack_codex_ for namespace hygiene): +# _gstack_codex_auth_probe — multi-signal auth check (env + file) +# _gstack_codex_model_probe — round-trip probe of the configured model (#2477) +# _gstack_codex_version_check — warn on known-bad Codex CLI versions +# _gstack_codex_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback +# _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/ +# +# Hygiene rules (enforced by test/codex-hardening.test.ts): +# - Never set -e / set -u / trap / IFS= / PATH= in this file. +# - All internal vars prefix with _GSTACK_CODEX_. +# - All functions prefix with _gstack_codex_. +# - No command execution at source time (only function defs). + +# --- Auth probe ------------------------------------------------------------- + +_gstack_codex_auth_probe() { + # Multi-signal: env vars OR auth file. Avoids false negatives for env-auth + # users (CI, platform engineers) that a file-only check would reject. + local _codex_home="${CODEX_HOME:-$HOME/.codex}" + # Use `-n` which returns true only for non-empty non-whitespace. Bash's [ -n ] + # alone allows whitespace; pair with a whitespace strip for robustness. + local _k1 _k2 + _k1=$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]') + _k2=$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]') + if [ -n "$_k1" ] || [ -n "$_k2" ] || [ -f "$_codex_home/auth.json" ]; then + echo "AUTH_OK" + return 0 + fi + echo "AUTH_FAILED" + return 1 +} + +# --- Model round-trip probe (#2477) ------------------------------------------ + +_gstack_codex_model_probe() { + # Auth-exists is a weaker signal than the auth probe implies: a ChatGPT + # account with a stale `model = "..."` pin in ~/.codex/config.toml passes + # the auth probe, then EVERY invocation dies with an HTTP 400 ("The + # '' model is not supported when using Codex with a ChatGPT + # account") and no guidance. A short real round trip with the configured + # model catches model rejection, entitlement changes, and stale pins in + # one shot (#2477). + # + # Contract: + # MODEL_OK (exit 0) — round trip succeeded; cached 1h. + # MODEL_UNUSABLE (exit 1) — deterministic model 400; hints printed. + # Cached 15 min: the 400 is config-driven, so re-probing every preflight + # charged the affected user a 30s round trip + real tokens per review + # section, forever. Editing config.toml (the fix) changes the cache + # signature and re-probes immediately; the short TTL covers server-side + # entitlement recovery the signature can't see. + # MODEL_PROBE_INCONCLUSIVE (exit 0) — timeout/transient; FAIL-OPEN so a + # slow network never wedges codex mode (the per-invocation Error + # Handling entry still covers a later 400). Never cached. + # + # Only call this AFTER _gstack_codex_auth_probe passes — probing without + # auth just measures the auth failure again. + local _codex_home="${CODEX_HOME:-$HOME/.codex}" + local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}" + local _cache="$_gstack_home/.codex-model-probe" + # Cache signature: config.toml + auth.json mtimes. Editing the model pin + # or re-logging-in invalidates the cached MODEL_OK immediately. + # GNU-first stat order + numeric validation (the #2195 pattern): on GNU + # stat, `-f` means FILESYSTEM mode, so the BSD-first form emitted a + # multi-line filesystem block on Linux — the signature then never matched + # its own cache line and the cache missed on every read. BSD stat rejects + # `-c` cleanly, so GNU-first degrades correctly on macOS. + local _cfg_m _auth_m _sig + _cfg_m=$(stat -c %Y "$_codex_home/config.toml" 2>/dev/null || stat -f %m "$_codex_home/config.toml" 2>/dev/null || echo 0) + _auth_m=$(stat -c %Y "$_codex_home/auth.json" 2>/dev/null || stat -f %m "$_codex_home/auth.json" 2>/dev/null || echo 0) + case "$_cfg_m" in ''|*[!0-9]*) _cfg_m=0 ;; esac + case "$_auth_m" in ''|*[!0-9]*) _auth_m=0 ;; esac + _sig="${_cfg_m}-${_auth_m}" + local _now + _now=$(date +%s 2>/dev/null || echo 0) + if [ -f "$_cache" ]; then + local _c_line _c_status _c_ts _c_sig + _c_line=$(head -1 "$_cache" 2>/dev/null) + _c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1) + _c_ts=$(printf '%s' "$_c_line" | cut -d' ' -f2) + _c_sig=$(printf '%s' "$_c_line" | cut -d' ' -f3) + case "$_c_ts" in ''|*[!0-9]*) _c_ts=0 ;; esac + if [ "$_c_status" = "MODEL_OK" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 3600 ]; then + echo "MODEL_OK (cached)" + return 0 + fi + if [ "$_c_status" = "MODEL_UNUSABLE" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then + echo "MODEL_UNUSABLE (cached)" + echo "HINT: the rejected model comes from the 'model = ' line in $_codex_home/config.toml." + echo "HINT: check its [notice.model_migrations] table — Codex records the intended replacement there." + return 1 + fi + fi + local _out _code + _out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only "reply OK" &1) + _code=$? + if [ "$_code" -eq 0 ]; then + mkdir -p "$_gstack_home" 2>/dev/null || true + printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + echo "MODEL_OK" + return 0 + fi + if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then + mkdir -p "$_gstack_home" 2>/dev/null || true + printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + echo "MODEL_UNUSABLE" + printf '%s\n' "$_out" | grep -i "model" | head -3 + echo "HINT: the rejected model comes from the 'model = ' line in $_codex_home/config.toml." + echo "HINT: check its [notice.model_migrations] table — Codex records the intended replacement there." + _gstack_codex_log_event "codex_model_unusable" 2>/dev/null || true + return 1 + fi + # Timeout (124) or transient failure: fail-open with a warning. The probe + # exists to catch the deterministic model 400, not to gate on network luck. + echo "MODEL_PROBE_INCONCLUSIVE (exit $_code) — proceeding; if invocations fail with a model 400, see the codex skill's Error Handling entry." + return 0 +} + +# --- Version check ---------------------------------------------------------- + +_gstack_codex_version_check() { + # Warn on known-bad Codex CLI versions. Anchored regex prevents false + # positives like 0.120.10 or 0.120.20 from matching. 0.120.2-beta still + # matches the bad release and gets warned (it IS buggy). + # Update this list when a new Codex CLI version regresses. + local _ver + _ver=$(codex --version 2>/dev/null | head -1) + [ -z "$_ver" ] && return 0 + if echo "$_ver" | grep -Eq '(^|[^0-9.])0\.120\.(0|1|2)([^0-9.]|$)'; then + echo "WARN: Codex CLI $_ver has known stdin deadlock bugs. Run: npm install -g @openai/codex@latest" + _gstack_codex_log_event "codex_version_warning" + fi +} + +# --- Timeout wrapper -------------------------------------------------------- + +_gstack_codex_timeout_wrapper() { + # Resolve wrapper binary: prefer gtimeout (Homebrew coreutils on macOS), + # fall back to timeout (Linux), else a bash-native watchdog. Arguments: + # $1 is the duration in seconds; rest is the command to run. + local _duration="$1" + shift + local _to + _to=$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || echo "") + if [ -n "$_to" ]; then + "$_to" "$_duration" "$@" + else + # Stock macOS ships neither coreutils gtimeout nor timeout(1); running + # unwrapped let a hung `codex exec` block the probe — and the calling + # workflow — indefinitely. Emulate: background the command, TERM it at + # the deadline, mirror timeout(1)'s exit-124 contract. The watchdog's + # stdout is detached so an early finish never blocks a caller's $(...) + # capture on the orphaned sleep. + "$@" & + local _cmd_pid=$! + ( sleep "$_duration" && kill -TERM "$_cmd_pid" 2>/dev/null ) >/dev/null 2>&1 & + local _watch_pid=$! + local _rc + wait "$_cmd_pid" + _rc=$? + if kill -0 "$_watch_pid" 2>/dev/null; then + # Command finished before the deadline. Retiring the watchdog subshell + # also defuses its pending kill (the `&& kill` lives in the subshell); + # its detached sleep expires harmlessly. + kill "$_watch_pid" 2>/dev/null + wait "$_watch_pid" 2>/dev/null + elif [ "$_rc" -ge 128 ]; then + _rc=124 # killed by the watchdog: report timeout(1)'s code + fi + return "$_rc" + fi +} + +# --- Telemetry event -------------------------------------------------------- + +_gstack_codex_log_event() { + # Emit a telemetry event to ~/.gstack/analytics/skill-usage.jsonl. + # Gated on $_TEL != "off" (caller sets this from gstack-config). + # Event types: codex_timeout, codex_auth_failed, codex_cli_missing, + # codex_version_warning, codex_model_unusable. + # Payload schema: {skill, event, duration_s, ts}. NEVER includes prompt + # content, env var values, or auth tokens. + local _event="$1" + local _duration="${2:-0}" + [ "${_TEL:-off}" = "off" ] && return 0 + mkdir -p "$HOME/.gstack/analytics" 2>/dev/null || return 0 + local _ts + _ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown) + printf '{"skill":"codex","event":"%s","duration_s":"%s","ts":"%s"}\n' \ + "$_event" "$_duration" "$_ts" \ + >> "$HOME/.gstack/analytics/skill-usage.jsonl" 2>/dev/null || true +} + +# --- Learnings log on hang -------------------------------------------------- + +_gstack_codex_log_hang() { + # Invoked when a codex invocation times out (exit 124). Records an + # operational learning so future /investigate sessions surface the pattern. + # Best-effort: errors swallowed. + local _mode="${1:-unknown}" + local _prompt_size="${2:-0}" + local _log_bin="$HOME/.claude/skills/gstack/bin/gstack-learnings-log" + [ -x "$_log_bin" ] || return 0 + local _key="codex-hang-$(date +%s 2>/dev/null || echo unknown)" + "$_log_bin" "$(printf '{"skill":"codex","type":"operational","key":"%s","insight":"Codex timed out after 600s during [%s] invocation. Prompt size: %s. Consider splitting prompt or checking network.","confidence":8,"source":"observed","files":["codex/SKILL.md.tmpl","autoplan/SKILL.md.tmpl"]}' "$_key" "$_mode" "$_prompt_size")" \ + >/dev/null 2>&1 || true +} diff --git a/.agents/skills/gstack/bin/gstack-codex-session-import b/.agents/skills/gstack/bin/gstack-codex-session-import new file mode 100755 index 0000000..7b1c5f0 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-codex-session-import @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# gstack-codex-session-import — backfill question-log.jsonl from Codex sessions. +# +# Codex has no AskUserQuestion tool (per docs/spikes/codex-session-format.md). +# gstack skills running on Codex emit Decision Briefs as plain agent_message +# text, and the user's response shows up in the next user_message. This +# importer reconstructs those question/answer pairs from the structured +# JSONL session files at ~/.codex/sessions//. +# +# Usage: +# gstack-codex-session-import # latest session under ~/.codex/sessions/ +# gstack-codex-session-import # explicit session file +# gstack-codex-session-import --since # all sessions newer than +# +# Recovery strategy (two-tier per D5/T4 spike): +# 1. Marker-first: extract from agent_message → stable id. +# 2. Pattern fallback: detect D header + numbered options → hash id +# (source=codex-import-pattern, never used as preference key per D18). +# +# Writes via bin/gstack-question-log so source tagging, dedup, and async +# derive all apply uniformly. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}" + +MODE="latest" +EXPLICIT_PATH="" +SINCE_ISO="" + +if [ $# -gt 0 ]; then + case "$1" in + --since) + MODE="since" + SINCE_ISO="${2:-}" + ;; + --help|-h) + sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||' + exit 0 + ;; + -*) + echo "unknown flag: $1" >&2 + exit 1 + ;; + *) + MODE="explicit" + EXPLICIT_PATH="$1" + ;; + esac +fi + +# Resolve list of session files to process. +SESSION_FILES=() +case "$MODE" in + explicit) + if [ ! -f "$EXPLICIT_PATH" ]; then + echo "gstack-codex-session-import: file not found: $EXPLICIT_PATH" >&2 + exit 1 + fi + SESSION_FILES=("$EXPLICIT_PATH") + ;; + latest) + if [ ! -d "$CODEX_SESSIONS_ROOT" ]; then + echo "NO_SESSIONS: $CODEX_SESSIONS_ROOT does not exist" + exit 0 + fi + # xargs -r: GNU xargs runs `ls -t` once even on EMPTY input (listing the + # cwd and producing a bogus LATEST); BSD xargs skips it. -r pins the + # BSD behavior on both. + LATEST=$(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -print 2>/dev/null \ + | xargs -r ls -t 2>/dev/null | head -1 || true) + if [ -z "$LATEST" ]; then + echo "NO_SESSIONS: no rollout-*.jsonl files under $CODEX_SESSIONS_ROOT" + exit 0 + fi + SESSION_FILES=("$LATEST") + ;; + since) + if [ -z "$SINCE_ISO" ]; then + echo "--since requires an ISO 8601 timestamp" >&2 + exit 1 + fi + while IFS= read -r f; do + SESSION_FILES+=("$f") + done < <(find "$CODEX_SESSIONS_ROOT" -type f -name "rollout-*.jsonl" -newer <(date -u -d "$SINCE_ISO" 2>/dev/null || date -u) 2>/dev/null) + ;; +esac + +if [ ${#SESSION_FILES[@]} -eq 0 ]; then + echo "NO_SESSIONS: nothing to import" + exit 0 +fi + +# Parse + extract via bun. Emits one line per question found, ready to pipe +# into gstack-question-log. Tagged with source so downstream consumers +# (/plan-tune stats, dream cycle) can distinguish backfilled events from +# live captures. +IMPORTED=0 +SKIPPED_NO_ANSWER=0 + +for SESSION_FILE in "${SESSION_FILES[@]}"; do + COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun -e ' + const fs = require("fs"); + const path = require("path"); + const { spawnSync } = require("child_process"); + const crypto = require("crypto"); + + const sessionPath = process.env.SESSION_FILE_PATH; + const qlogBin = process.env.QLOG_BIN; + const lines = fs.readFileSync(sessionPath, "utf-8").trim().split("\n").filter(Boolean); + + let meta = null; + const stream = []; + for (const ln of lines) { + try { + const e = JSON.parse(ln); + if (e.type === "session_meta") meta = e.payload; + else stream.push(e); + } catch {} + } + if (!meta) { + console.error("WARN: no session_meta in " + sessionPath); + console.log("0 0"); + process.exit(0); + } + + const cwd = meta.cwd || ""; + const sessionId = (meta.id || path.basename(sessionPath)).slice(0, 64); + + // Walk for agent_message → next user_message pairs. + const briefs = []; + for (let i = 0; i < stream.length; i++) { + const e = stream[i]; + if (e.type !== "event_msg" || e.payload?.type !== "agent_message") continue; + const text = String(e.payload?.message || ""); + if (!text) continue; + // Detect D-numbered brief or marker. Markers are sufficient on their own. + const markerMatch = text.match(//i); + const dMatch = text.match(/^D\d+[\.\d]*\s*[—\-]\s*(.+?)$/m); + if (!markerMatch && !dMatch) continue; + + // Find the next user_message in the stream. + let answer = null; + for (let j = i + 1; j < stream.length; j++) { + const e2 = stream[j]; + if (e2.type === "event_msg" && e2.payload?.type === "user_message") { + answer = String(e2.payload?.message || "").trim(); + break; + } + } + if (!answer) continue; + + // Extract options A) ... B) ... from the brief. + const optMatches = [...text.matchAll(/^([A-Z])\)\s+(.+?)(?:\s+\(recommended\))?$/gm)]; + const options = optMatches.map((m) => m[2].trim()); + + // Identify recommended option (label first, prose fallback). + let recommended; + const recLabel = [...text.matchAll(/^([A-Z])\)\s+(.+?)\s+\(recommended\)$/gm)]; + if (recLabel.length === 1) recommended = recLabel[0][2].trim(); + + // Identify which option the user picked from their answer. + // Look for "A" / "A) ..." / option-label prefix match. + let userChoice = "__unknown__"; + const letterMatch = answer.match(/^\s*([A-Z])\b/); + if (letterMatch) { + const idx = letterMatch[1].charCodeAt(0) - 65; + if (idx >= 0 && idx < options.length) userChoice = options[idx]; + else userChoice = letterMatch[1]; + } else if (options.length > 0) { + const lower = answer.toLowerCase(); + const m = options.find((o) => lower.includes(o.toLowerCase().slice(0, 12))); + if (m) userChoice = m; + } + if (userChoice === "__unknown__") { + userChoice = answer.slice(0, 64); + } + + const summary = (dMatch?.[1] || text.split("\n")[0]).slice(0, 200); + + let questionId, source; + if (markerMatch) { + questionId = markerMatch[1]; + source = "codex-import-marker"; + } else { + const sortedOpts = [...options].sort().join("|"); + const h = crypto.createHash("sha1").update("codex::" + summary + "::" + sortedOpts).digest("hex").slice(0, 10); + questionId = "hook-" + h; + source = "codex-import-pattern"; + } + + briefs.push({ + skill: "codex", + question_id: questionId, + question_summary: summary, + options_count: options.length || 1, + user_choice: userChoice.slice(0, 64), + ...(recommended ? { recommended: recommended.slice(0, 64) } : {}), + source, + session_id: sessionId, + // Use ts_nanos+ts shape from the event itself if available; else null. + ts: e.timestamp || undefined, + }); + } + + let imported = 0; + for (const b of briefs) { + const res = spawnSync(qlogBin, [JSON.stringify(b)], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + // Run from the originating cwd so gstack-slug bucks events into the + // right project. Falls back to the importer cwd if the session cwd + // no longer exists. + cwd: cwd && fs.existsSync(cwd) ? cwd : undefined, + timeout: 5000, + }); + if (res.status === 0) imported++; + } + console.log(imported + " 0"); + ' 2>&1) + + IMP=$(echo "$COUNT_LINE" | awk "{print \$1}") + IMPORTED=$((IMPORTED + IMP)) +done + +echo "IMPORTED: $IMPORTED events from ${#SESSION_FILES[@]} session(s)" diff --git a/.agents/skills/gstack/bin/gstack-community-dashboard b/.agents/skills/gstack/bin/gstack-community-dashboard new file mode 100755 index 0000000..2b40b25 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-community-dashboard @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# gstack-community-dashboard — community usage stats from Supabase +# +# Calls the community-pulse edge function for aggregated stats: +# skill popularity, crash clusters, version distribution, retention. +# +# Env overrides (for testing): +# GSTACK_DIR — override auto-detected gstack root +# GSTACK_SUPABASE_URL — override Supabase project URL +# GSTACK_SUPABASE_ANON_KEY — override Supabase anon key +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" + +# Egress receipt helpers (_receipted_curl): fail-open for read-only stats. +. "$GSTACK_DIR/bin/gstack-egress-lib.sh" + +# Source Supabase config if not overridden by env +if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then + . "$GSTACK_DIR/supabase/config.sh" +fi +SUPABASE_URL="${GSTACK_SUPABASE_URL:-}" +ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" + +if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then + echo "gstack community dashboard" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Supabase not configured yet. The community dashboard will be" + echo "available once the gstack Supabase project is set up." + echo "" + echo "For local analytics, run: gstack-analytics" + exit 0 +fi + +# ─── Fetch aggregated stats from edge function ──────────────── +# HTTP status captured (#1947): a backend failure must read as "unknown", +# never as a healthy "Weekly active installs: 0". +TMPBODY="$(mktemp)" +trap 'rm -f "$TMPBODY"' EXIT +SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}" +HTTP_CODE="$(_receipted_curl open community-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \ + curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \ + "${SUPABASE_URL}/functions/v1/community-pulse" \ + -H "apikey: ${ANON_KEY}" || true)" +# curl prints its own 000 before a non-zero exit — a `|| echo` here would +# double it to "000000" in user-facing output. Normalize to the last 3 chars. +HTTP_CODE="$(printf '%s' "$HTTP_CODE" | tr -d '[:space:]' | tail -c 3)" +[ -n "$HTTP_CODE" ] || HTTP_CODE="000" +DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")" + +echo "gstack community dashboard" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ] || ! printf '%s' "$DATA" | grep -q '"weekly_active"'; then + echo "Community stats: unknown — backend error (HTTP ${HTTP_CODE})" + echo "" + echo "For local analytics: gstack-analytics" + exit 0 +fi + +# ─── Weekly active installs ────────────────────────────────── +WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" +CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" + +echo "Weekly active installs: ${WEEKLY}" +# Marker check: jq when available (whitespace/reserialization-proof); the +# grep fallback tolerates optional whitespace around the colon. +_STALE="false" +if command -v jq >/dev/null 2>&1; then + _MARKER="$(printf '%s' "$DATA" | jq -r '.status // empty' 2>/dev/null)" + _STALE="$(printf '%s' "$DATA" | jq -r '.stale // false' 2>/dev/null)" +else + _MARKER="$(printf '%s' "$DATA" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"' && echo ok || true)" +fi +if [ "$_MARKER" != "ok" ]; then + echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)" +elif [ "$_STALE" = "true" ]; then + # Backend serves its last good snapshot when recompute fails — real but + # frozen figures must not read as current (matches security-dashboard). + echo " (stale snapshot — backend recompute failing; figures may be out of date)" +fi +if [ "$CHANGE" -gt 0 ] 2>/dev/null; then + echo " Change: +${CHANGE}%" +elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then + echo " Change: ${CHANGE}%" +fi +echo "" + +# ─── Skill popularity (top 10) ─────────────────────────────── +echo "Top skills (last 7 days)" +echo "────────────────────────" + +# Parse top_skills array from JSON +SKILLS="$(echo "$DATA" | grep -o '"top_skills":\[[^]]*\]' || echo "")" +if [ -n "$SKILLS" ] && [ "$SKILLS" != '"top_skills":[]' ]; then + # Parse each object — handle any key order (JSONB doesn't preserve order) + echo "$SKILLS" | grep -o '{[^}]*}' | while read -r OBJ; do + SKILL="$(echo "$OBJ" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}')" + COUNT="$(echo "$OBJ" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')" + [ -n "$SKILL" ] && [ -n "$COUNT" ] && printf " /%-20s %s runs\n" "$SKILL" "$COUNT" + done +else + echo " No data yet" +fi +echo "" + +# ─── Crash clusters ────────────────────────────────────────── +echo "Top crash clusters" +echo "──────────────────" + +CRASHES="$(echo "$DATA" | grep -o '"crashes":\[[^]]*\]' || echo "")" +if [ -n "$CRASHES" ] && [ "$CRASHES" != '"crashes":[]' ]; then + echo "$CRASHES" | grep -o '{[^}]*}' | head -5 | while read -r OBJ; do + ERR="$(echo "$OBJ" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}')" + C="$(echo "$OBJ" | grep -o '"total_occurrences":[0-9]*' | grep -o '[0-9]*')" + [ -n "$ERR" ] && printf " %-30s %s occurrences\n" "$ERR" "${C:-?}" + done +else + echo " No crashes reported" +fi +echo "" + +# ─── Version distribution ──────────────────────────────────── +echo "Version distribution (last 7 days)" +echo "───────────────────────────────────" + +VERSIONS="$(echo "$DATA" | grep -o '"versions":\[[^]]*\]' || echo "")" +if [ -n "$VERSIONS" ] && [ "$VERSIONS" != '"versions":[]' ]; then + echo "$VERSIONS" | grep -o '{[^}]*}' | head -5 | while read -r OBJ; do + VER="$(echo "$OBJ" | grep -o '"version":"[^"]*"' | awk -F'"' '{print $4}')" + COUNT="$(echo "$OBJ" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')" + [ -n "$VER" ] && [ -n "$COUNT" ] && printf " v%-15s %s events\n" "$VER" "$COUNT" + done +else + echo " No data yet" +fi + +echo "" +echo "For local analytics: gstack-analytics" diff --git a/.agents/skills/gstack/bin/gstack-config b/.agents/skills/gstack/bin/gstack-config new file mode 100755 index 0000000..3521825 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-config @@ -0,0 +1,561 @@ +#!/usr/bin/env bash +# gstack-config — read/write ~/.gstack/config.yaml +# +# Usage: +# gstack-config get — read a config value (falls back to DEFAULTS) +# gstack-config has — exit 0 iff the key is literally present in the +# config file (get returns DEFAULTS for absent keys, +# so callers that need provenance use this instead) +# gstack-config set — write a config value +# gstack-config list — show all config (values + defaults) +# gstack-config defaults — show just the defaults table +# +# Env overrides (for testing): +# GSTACK_STATE_ROOT — override ~/.gstack state directory (highest priority, +# matches D16 cathedral isolation convention) +# GSTACK_HOME — override ~/.gstack state directory (aligns with writer scripts) +# GSTACK_STATE_DIR — legacy alias for GSTACK_HOME (kept for backwards compat) +set -euo pipefail + +STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" +CONFIG_FILE="$STATE_DIR/config.yaml" + +# Swap a freshly-rendered tmp dir into the live render location (#2569 +# hardening). Installed skills SYMLINK into the live dir, so it is only ever +# replaced AFTER a successful render — a failed render leaves the previous +# render (and every link into it) fully intact. Keep in sync with setup's +# _swap_in_render (same contract, both pinned by +# test/user-render-out-dir-install.test.ts). +_swap_in_render() { + local render_dir="$1" render_tmp="$2" + local render_old="$render_dir.old.$$" + rm -rf "$render_old" + if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi + mv "$render_tmp" "$render_dir" + rm -rf "$render_old" +} + +# Annotated header for new config files. Written once on first `set`. +# Default semantics: DEFAULTS table below is the canonical source. Header text +# is documentation that must stay in sync with DEFAULTS. +CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on next skill run. +# Docs: https://github.com/garrytan/gstack +# +# ─── Behavior ──────────────────────────────────────────────────────── +# proactive: true # Auto-invoke skills when your request matches one. +# # Set to false to only run skills you type explicitly. +# +# routing_declined: false # Set to true to skip the CLAUDE.md routing injection +# # prompt. Set back to false to be asked again. +# +# ─── Telemetry ─────────────────────────────────────────────────────── +# telemetry: off # off | anonymous | community +# # off — no data sent, no local analytics (default) +# # anonymous — counter only, no device ID +# # community — usage data + stable device ID +# +# ─── Updates ───────────────────────────────────────────────────────── +# auto_upgrade: false # true = silently upgrade on session start +# update_check: true # false = suppress version check notifications +# +# ─── Skill naming ──────────────────────────────────────────────────── +# skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship +# # false = short names /qa, /ship +# +# ─── Checkpoint ────────────────────────────────────────────────────── +# checkpoint_mode: explicit # explicit | continuous +# # explicit — commit only when you run /ship or /checkpoint +# # continuous — auto-commit after each significant change +# # with WIP: prefix + [gstack-context] body +# +# checkpoint_push: false # true = push WIP commits to remote as you go +# # false = keep WIP commits local only (default) +# # Pushing can trigger CI/deploy hooks — opt in carefully. +# +# ─── Writing style (V1) ────────────────────────────────────────────── +# explain_level: default # default = jargon-glossed, outcome-framed prose +# # (V1 default — more accessible for everyone) +# # terse = V0 prose style, no glosses, no outcome-framing layer +# # (for power users who know the terms) +# # Unknown values default to "default" with a warning. +# # See docs/designs/PLAN_TUNING_V1.md for rationale. +# +# ─── Artifacts sync (renamed from gbrain_sync_mode in v1.27.0.0) ───── +# artifacts_sync_mode: off # off | artifacts-only | full +# # off — no sync (default) +# # artifacts-only — sync plans/designs/retros/learnings only +# # (skip behavioral data: question-log, +# # developer-profile, timeline) +# # full — sync everything allowlisted +# # Set by the first-run privacy stop-gate. See docs/gbrain-sync.md. +# +# artifacts_sync_mode_prompted: false +# # Set to true once the privacy gate has asked the user. +# # Flip back to false to be re-prompted. +# +# ─── Plan-tune hooks ───────────────────────────────────────────────── +# plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune +# # Claude Code hooks (PostToolUse capture + +# # PreToolUse preference enforcement). +# # prompt — ask on a real TTY, skip otherwise (default) +# # yes — install non-interactively +# # no — skip non-interactively +# # Override per-run: ./setup --plan-tune-hooks / +# # --no-plan-tune-hooks, or env GSTACK_PLAN_TUNE_HOOKS. +# +# ─── Advanced ──────────────────────────────────────────────────────── +# codex_reviews: enabled # Master switch for Codex cross-model review. enabled = +# # Codex runs as a standard step in /review, /ship, +# # /document-release, plan reviews, and /autoplan (auto +# # falls back to a Claude subagent if Codex is missing or +# # not authenticated). disabled = skip all Codex passes. +# # Asymmetry on disabled: diff-review (/review, /ship) still +# # runs the free Claude adversarial subagent; plan-review and +# # /document-release skip the outside-voice step entirely. +# # An invalid value is REJECTED (existing value preserved) so +# # a typo cannot silently turn paid Codex calls on or off. +# gstack_contributor: false # true = file field reports when gstack misbehaves +# skip_eng_review: false # true = skip eng review gate in /ship (not recommended) +# +# ─── Workspace-aware ship ──────────────────────────────────────────── +# workspace_root: $HOME/conductor/workspaces # Where /ship looks for sibling +# # Conductor worktrees when picking a VERSION slot. +# # Set to "null" to disable sibling scanning entirely. +# # Non-Conductor users can point this at any directory +# # that holds parallel worktrees of the same repo. +# +' + +# DEFAULTS table — canonical default values for known keys. +# `get ` returns DEFAULTS[key] when the key is absent from the config file +# AND the env override is not set. Keep in sync with the CONFIG_HEADER comments. +lookup_default() { + case "$1" in + proactive) echo "true" ;; + routing_declined) echo "false" ;; + telemetry) echo "off" ;; + auto_upgrade) echo "false" ;; + update_check) echo "true" ;; + skill_prefix) echo "false" ;; + checkpoint_mode) echo "explicit" ;; + checkpoint_push) echo "false" ;; + explain_level) echo "default" ;; + codex_reviews) echo "enabled" ;; + gstack_contributor) echo "false" ;; + skip_eng_review) echo "false" ;; + workspace_root) echo "$HOME/conductor/workspaces" ;; + cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt + artifacts_sync_mode) echo "off" ;; + artifacts_sync_mode_prompted) echo "false" ;; + plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install + + redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection + redact_prepush_hook) echo "false" ;; + pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks + founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false + # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: + # brain_trust_policy@ — unset on fresh install; setup-gbrain + # writes 'personal' for local engines, + # asks the user for remote-ambiguous. + # salience_allowlist — empty falls through to + # SALIENCE_DEFAULT_ALLOWLIST (D9). + # user_slug_at_ — empty triggers resolve-user-slug + # fallback chain (D4 A3) on first call. + brain_trust_policy*) echo "unset" ;; + salience_allowlist) echo "" ;; + user_slug_at_*) echo "" ;; + # Read by skill preambles but missing from this table, so they fell through + # to the catch-all and came back "" with exit 0. Values below are the ones + # the callers already assume in their own `|| echo ""` fallback. + question_tuning) echo "false" ;; + team_mode) echo "false" ;; + transcript_ingest_mode) echo "off" ;; + # repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty + # answer as a user override and skips its own classification entirely, so + # a synthesized "unknown" default turns the classifier into dead code. + # Empty + exit 0 = "no override set, go classify". + repo_mode) echo "" ;; + # Unknown key: exit non-zero instead of printing "". The fallback pattern + # the preambles use, + # VAR=$(gstack-config get 2>/dev/null || echo "") + # only fires on a non-zero exit, so a catch-all echoing "" with exit 0 left + # VAR empty and the written default unreachable. + # Deliberately *only* the unknown-key path: the keys above whose default is + # intentionally empty (cross_project_learnings, salience_allowlist, + # user_slug_at_*, redact_repo_visibility) keep exit 0, because "" is their + # real answer and their callers rely on it. + *) return 1 ;; + esac +} + +# ────────────────────────────────────────────────────────────────────── +# Brain-integration helpers (T5+T10+T16) +# ────────────────────────────────────────────────────────────────────── + +# Compute sha8 of a string. Used for endpoint hashing. +# shasum is macOS/perl; most Linux distros ship only coreutils sha256sum — +# resolve whichever exists (same fallback chain as the codex-probe timeout +# wrapper). Without this, any Linux user with a git email hit exit 127 in +# resolve-user-slug's Layer-3 fallback. +sha8_of() { + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | cut -c1-8 + else + printf '%s' "$1" | shasum -a 256 | cut -c1-8 + fi +} + +# Detect the active brain endpoint hash. Reads ~/.claude.json for the gbrain +# MCP server URL. Falls back to the literal 'local' when no MCP is configured. +endpoint_hash() { + _claude_json="$HOME/.claude.json" + if [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then + _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) + if [ -n "$_url" ] && [ "$_url" != "null" ]; then + sha8_of "$_url" + return 0 + fi + fi + printf '%s' "local" +} + +# Detect endpoint hash collisions. When two distinct endpoints share the same +# sha8 prefix (rare but possible), escalate to sha16 by emitting the longer +# hash. Detection: scan config file for existing brain_trust_policy@ or +# user_slug_at_ keys; if any non-active hash equals the active sha8 but +# would differ at sha16, the active endpoint needs sha16. +endpoint_hash_with_collision_check() { + _active=$(endpoint_hash) + if [ "$_active" = "local" ]; then + printf '%s' "$_active" + return 0 + fi + # If a different endpoint (different URL) shares this sha8, escalate. + # We only catch this when the config has another endpoint recorded. + _matching=$(grep -E "^(brain_trust_policy|user_slug_at)@${_active}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) + _claude_json="$HOME/.claude.json" + if [ -n "$_matching" ] && [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then + _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) + if command -v sha256sum >/dev/null 2>&1; then + _sha16=$(printf '%s' "$_url" | sha256sum | cut -c1-16) + else + _sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16) + fi + # Look for any sha16-namespaced key that conflicts. If a stored sha16 exists + # and differs from current sha16, that's the collision evidence; emit sha16. + _stored16=$(grep -E "^(brain_trust_policy|user_slug_at)@${_sha16}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) + if [ -n "$_stored16" ]; then + printf '%s' "$_sha16" + return 0 + fi + fi + printf '%s' "$_active" +} + +# Resolve the user-slug per D4 A3 chain: +# 1. mcp__gbrain__whoami.client_name (best effort via gbrain CLI shell-out) +# 2. $USER env +# 3. sha8($(git config user.email)) +# 4. anonymous- +# Persists result via gstack-config set user_slug_at_ on first call. +resolve_user_slug() { + _hash=$(endpoint_hash_with_collision_check) + _stored=$(grep -E "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) + if [ -n "$_stored" ]; then + printf '%s' "$_stored" + return 0 + fi + + _slug="" + + # Layer 1: gbrain whoami + if command -v gbrain >/dev/null 2>&1; then + _whoami=$(gbrain whoami --json 2>/dev/null || true) + if [ -n "$_whoami" ] && command -v jq >/dev/null 2>&1; then + _client_name=$(printf '%s' "$_whoami" | jq -r '.client_name // .token_name // empty' 2>/dev/null || true) + if [ -n "$_client_name" ] && [ "$_client_name" != "null" ]; then + _slug=$(printf '%s' "$_client_name" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-') + fi + fi + fi + + # Layer 2: $USER + if [ -z "$_slug" ] && [ -n "${USER:-}" ]; then + _slug=$(printf '%s' "$USER" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-') + fi + + # Layer 3: sha8 of git email + if [ -z "$_slug" ]; then + _email=$(git config user.email 2>/dev/null || true) + if [ -n "$_email" ]; then + _slug="email-$(sha8_of "$_email")" + fi + fi + + # Layer 4: anonymous- + if [ -z "$_slug" ]; then + _slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")" + fi + + # Persist via direct file write (avoid recursion into gstack-config set) + mkdir -p "$STATE_DIR" + if [ ! -f "$CONFIG_FILE" ]; then + printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" + fi + if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then + echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE" + fi + + printf '%s' "$_slug" +} + +read_config_value() { + local key="$1" + if [ ! -f "$CONFIG_FILE" ]; then + return 0 + fi + grep -E "^${key}:" "$CONFIG_FILE" 2>/dev/null \ + | tail -1 \ + | sed -E "s/^${key}:[[:space:]]*//; s/[[:space:]]+$//" +} + +case "${1:-}" in + get) + KEY="${2:?Usage: gstack-config get }" + # Validate key (alphanumeric + underscore + optional @ suffix for + # endpoint-namespaced keys introduced by the brain-aware planning layer). + # Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal + # "local" for stdio/PGLite engines (see endpoint_hash). + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + exit 1 + fi + VALUE=$(read_config_value "$KEY" || true) + if [ -z "$VALUE" ]; then + # lookup_default exits non-zero for a key it does not know. Propagate + # that, so the caller's `|| echo ""` can fire. A known key whose + # default is empty still exits 0 and prints "". + if ! VALUE=$(lookup_default "$KEY"); then + exit 1 + fi + fi + printf '%s' "$VALUE" + ;; + has) + KEY="${2:?Usage: gstack-config has }" + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + exit 1 + fi + grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null + ;; + set) + KEY="${2:?Usage: gstack-config set }" + VALUE="${3:?Usage: gstack-config set }" + # Validate key (alphanumeric + underscore + optional @ suffix). + # Accepts hex hashes and the literal "local" from endpoint_hash. + if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 + exit 1 + fi + # Validate brain_trust_policy value domain (D4 / D11) + if printf '%s' "$KEY" | grep -qE '^brain_trust_policy(@|$)' && \ + [ "$VALUE" != "personal" ] && [ "$VALUE" != "shared" ] && [ "$VALUE" != "unset" ]; then + echo "Warning: brain_trust_policy '$VALUE' not recognized. Valid values: personal, shared, unset. Using unset." >&2 + VALUE="unset" + fi + # V1: whitelist values for keys with closed value domains. Unknown values warn + default. + if [ "$KEY" = "explain_level" ] && [ "$VALUE" != "default" ] && [ "$VALUE" != "terse" ]; then + echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2 + VALUE="default" + fi + if [ "$KEY" = "artifacts_sync_mode" ] && [ "$VALUE" != "off" ] && [ "$VALUE" != "artifacts-only" ] && [ "$VALUE" != "full" ]; then + echo "Warning: artifacts_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2 + VALUE="off" + fi + # redact_repo_visibility: a LOCAL override for repos gh/glab can't read (e.g. + # self-hosted GitLab). It lives in ~/.gstack/config.yaml (never committed), so + # it can't be used to weaken the gate repo-wide for other contributors. + if [ "$KEY" = "redact_repo_visibility" ] && [ "$VALUE" != "public" ] && [ "$VALUE" != "private" ] && [ "$VALUE" != "unknown" ]; then + echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2 + VALUE="unknown" + fi + if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then + echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2 + VALUE="false" + fi + if [ "$KEY" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then + echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2 + VALUE="off" + fi + if [ "$KEY" = "founder_resources" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then + echo "Warning: founder_resources '$VALUE' not recognized. Valid values: true, false. Using true." >&2 + VALUE="true" + fi + if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then + echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2 + VALUE="prompt" + fi + # codex_reviews controls PAID Codex calls. Unlike the warn-and-default keys above, + # an invalid value is REJECTED and the existing setting is left unchanged — a typo + # must never silently flip the switch and turn paid Codex calls on or off. + if [ "$KEY" = "codex_reviews" ] && [ "$VALUE" != "enabled" ] && [ "$VALUE" != "disabled" ]; then + echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 + exit 1 + fi + mkdir -p "$STATE_DIR" + # Write annotated header on first creation + if [ ! -f "$CONFIG_FILE" ]; then + printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" + fi + # Drop embedded newlines, then escape sed replacement metacharacters. + SAFE_VALUE="$(printf '%s' "$VALUE" | head -1)" + ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')" + if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then + # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) + _tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")" + sed "/^${KEY}:/s/.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" + else + echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE" + fi + # Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call) + if [ "$KEY" = "skill_prefix" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then + GSTACK_RELINK="$(dirname "$0")/gstack-relink" + [ -x "$GSTACK_RELINK" ] && "$GSTACK_RELINK" || true + fi + ;; + list) + if [ -f "$CONFIG_FILE" ]; then + cat "$CONFIG_FILE" + fi + echo "" + echo "# ─── Active values (including defaults for unset keys) ───" + for KEY in proactive routing_declined telemetry auto_upgrade update_check \ + skill_prefix checkpoint_mode checkpoint_push explain_level \ + codex_reviews gstack_contributor skip_eng_review workspace_root \ + artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do + VALUE=$(read_config_value "$KEY" || true) + SOURCE="default" + if [ -n "$VALUE" ]; then + SOURCE="set" + else + VALUE=$(lookup_default "$KEY") + fi + printf ' %-24s %s (%s)\n' "$KEY:" "$VALUE" "$SOURCE" + done + ;; + defaults) + echo "# gstack-config defaults" + for KEY in proactive routing_declined telemetry auto_upgrade update_check \ + skill_prefix checkpoint_mode checkpoint_push explain_level \ + codex_reviews gstack_contributor skip_eng_review workspace_root \ + artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do + printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" + done + ;; + endpoint-hash) + # Brain integration helper (T10): print active brain endpoint sha8 + endpoint_hash_with_collision_check + ;; + resolve-user-slug) + # Brain integration helper (T16 / D4 A3): resolve + persist user-slug + resolve_user_slug + ;; + gbrain-refresh) + # Brain integration helper: re-detect gbrain installation state and + # persist to ~/.gstack/gbrain-detection.json. gen-skill-docs reads this + # file (when invoked with --respect-detection) to decide whether to + # render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks in + # generated SKILL.md files. + # + # Run this after installing or uninstalling gbrain so your locally + # generated SKILL.md files match your installation state. + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect" + DETECTION_FILE="$STATE_DIR/gbrain-detection.json" + mkdir -p "$STATE_DIR" + if [ ! -x "$DETECT_BIN" ]; then + echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2 + exit 1 + fi + if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then + printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp" + fi + mv "$DETECTION_FILE.tmp" "$DETECTION_FILE" + + # Summarize for the user. Use python (already required elsewhere) to + # parse the JSON portably; fall back to grep if python is unavailable. + PYTHON_CMD=$(command -v python3 || command -v python || true) + if [ -n "$PYTHON_CMD" ]; then + STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_local_status','unknown'))" 2>/dev/null || echo unknown) + VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_version') or 'unknown')" 2>/dev/null || echo unknown) + else + STATUS=$(grep -o '"gbrain_local_status":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/') + VERSION=$(grep -o '"gbrain_version":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/') + [ -z "$STATUS" ] && STATUS=unknown + [ -z "$VERSION" ] && VERSION=unknown + fi + + case "$STATUS" in + ok|timeout|thin-client|engine-locked) + # "timeout" = slow-but-healthy engine (#1964); "thin-client" = + # remote-HTTP MCP brain, no local engine by design (#2051); + # "engine-locked" = same class (#2456): PGLite is single-writer, so a + # live `gbrain serve` (typically an MCP server) holds the embedded DB. + # gbrain is installed and healthy; a transient lock must not strip + # brain blocks out of every SKILL.md. All get the same treatment as + # "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs. + echo "Detected gbrain v$VERSION (local-status: $STATUS)." + # Render brain-aware blocks into an UNTRACKED out-dir (#2569) and + # repoint the installed skills at it — the old in-place render wrote + # into TRACKED files of the global install checkout, so the checkout + # stayed permanently dirty and every upgrade grew a redundant stash. + # Guards (never mutate an arbitrary directory): the install must + # exist, not be a symlink (a symlinked install points at a dev + # worktree — bin/dev-setup owns that flow), and look like a real + # gstack clone. + INSTALL_DIR="$HOME/.claude/skills/gstack" + RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + if [ ! -d "$INSTALL_DIR" ]; then + echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)" + elif [ -L "$INSTALL_DIR" ]; then + echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Run bin/dev-setup in that worktree instead." + elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then + echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it." + elif ! command -v bun >/dev/null 2>&1; then + echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'." + else + # Render into a tmp dir and swap it in only on SUCCESS. Installed + # skills SYMLINK into $RENDER_DIR (gstack-relink prefers it), so + # wiping it before the render meant one transient failure (bun + # error, disk full, broken template) left every brain-aware + # SKILL.md link dangling — the whole skill set vanished from + # Claude Code until a successful re-render. A failed render now + # leaves the previous render fully intact. + RENDER_TMP="$RENDER_DIR.tmp.$$" + rm -rf "$RENDER_TMP" + if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" >/dev/null 2>&1 ); then + _swap_in_render "$RENDER_DIR" "$RENDER_TMP" + # Repoint installed skills at the render — gstack-relink prefers + # the render dir when present. + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true + echo "Rendered brain-aware blocks into $RENDER_DIR — now live across all your projects' Claude sessions." + echo "The install checkout stays clean: upgrades no longer stash generated render dirt (#2569)." + else + rm -rf "$RENDER_TMP" + echo "Warning: render failed — previous render (if any) left in place, links stay valid." + echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error." + fi + fi + ;; + *) + echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files." + echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured." + ;; + esac + ;; + *) + echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]" + exit 1 + ;; +esac diff --git a/.agents/skills/gstack/bin/gstack-context-bill b/.agents/skills/gstack/bin/gstack-context-bill new file mode 100755 index 0000000..a0cac12 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-context-bill @@ -0,0 +1,8 @@ +#!/usr/bin/env bun +// gstack-context-bill — token bill-of-materials for an installed skills tree. +// All behavior lives in lib/context-bill.ts; this is the CLI shim. + +import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor +import { contextBillMain } from '../lib/context-bill'; + +process.exit(await contextBillMain(process.argv.slice(2))); diff --git a/.agents/skills/gstack/bin/gstack-decision-log b/.agents/skills/gstack/bin/gstack-decision-log new file mode 100755 index 0000000..bfe27ac --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-decision-log @@ -0,0 +1,116 @@ +#!/usr/bin/env bun +/** + * gstack-decision-log — append a durable decision (or supersede/redact/compact it). + * + * Usage: + * gstack-decision-log '{"decision":"...","rationale":"...","scope":"repo","source":"user"}' + * gstack-decision-log --supersede + * gstack-decision-log --redact + * gstack-decision-log --compact + * + * Event-sourced (lib/gstack-decision): every call appends an event and refreshes the + * bounded active snapshot. NON-INTERACTIVE — never prompts (agents/skills call this; + * a prompt would hang them). Validation + injection + HIGH-secret rejection happen in + * validateDecide; a rejected decision exits 1 with a message, nothing persisted. + */ + +import { dirname } from "path"; +import { mkdirpSync } from "../lib/fs-utils"; +import { spawnSync } from "child_process"; +import { + decisionPaths, + validateDecide, + makeRefEvent, + appendEvent, + rebuildSnapshot, + compact, + type DecisionEvent, +} from "../lib/gstack-decision"; +import { resolveSlug, gitBranch, flagValue } from "../lib/bin-context"; + +const HERE = import.meta.dir; + +const args = process.argv.slice(2); +const slug = resolveSlug(`${HERE}/gstack-slug`); +const paths = decisionPaths(slug); +// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a +// recursive mkdir on an existing dir (#2635), and this runs on every log call. +mkdirpSync(dirname(paths.log)); + +function enqueue(): void { + // Fire-and-forget cross-machine sync (no-op when artifacts_sync is off). + spawnSync(`${HERE}/gstack-brain-enqueue`, [`projects/${slug}/decisions.jsonl`], { stdio: "ignore" }); +} + +if (args.includes("--compact")) { + const r = compact(paths); + if (r.skipped) { + console.log("compact skipped: a concurrent write/compact is in progress; log left intact — re-run"); + process.exit(0); + } + console.log(`compacted: ${r.activeCount} active, ${r.archivedCount} archived, ${r.expungedCount} expunged`); + enqueue(); + process.exit(0); +} + +// The payload is identified by its leading `{`, not by "first non-flag arg" — a +// `--supersede '{...}'` call would otherwise mistake the target id for the payload. +const jsonArg = args.find((a) => a.trimStart().startsWith("{")); + +/** Parse + validate a decision payload. Exits 1 (nothing persisted) when it's bad. */ +function validPayload(raw: string): DecisionEvent { + let obj: Partial; + try { + obj = JSON.parse(raw); + } catch { + process.stderr.write("gstack-decision-log: invalid JSON\n"); + process.exit(1); + } + if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch(); + const res = validateDecide(obj); + if (!res.ok) { + process.stderr.write(`gstack-decision-log: ${res.error}\n`); + process.exit(1); + } + return res.event; +} + +const supersedeId = flagValue(args, "--supersede"); +const redactId = flagValue(args, "--redact"); +if (supersedeId || redactId) { + const kind = supersedeId ? "supersede" : "redact"; + const targetId = (supersedeId || redactId) as string; + if (targetId.trimStart().startsWith("{")) { + process.stderr.write(`gstack-decision-log: --${kind} needs the target decision id before the replacement JSON\n`); + process.exit(1); + } + if (kind === "redact" && jsonArg) { + process.stderr.write( + "gstack-decision-log: --redact expunges and takes no replacement; log the replacement in its own call so it isn't dropped\n", + ); + process.exit(1); + } + // Validate the replacement BEFORE anything is written, then append it FIRST and + // retire the old one SECOND. Appends are individually atomic, so the only visible + // interleaving is "both active" (recoverable); the reverse order could retire the + // old decision and lose the replacement the user was recording. + const replacement = jsonArg ? { ...validPayload(jsonArg), supersedes: targetId } : undefined; + if (replacement) appendEvent(paths, replacement); + appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" })); + rebuildSnapshot(paths); + enqueue(); + console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`); + process.exit(0); +} + +if (!jsonArg) { + process.stderr.write( + "gstack-decision-log: provide a JSON decision, or --supersede/--redact , or --compact\n", + ); + process.exit(1); +} +const event = validPayload(jsonArg); +appendEvent(paths, event); +rebuildSnapshot(paths); +enqueue(); +console.log(event.id); diff --git a/.agents/skills/gstack/bin/gstack-decision-search b/.agents/skills/gstack/bin/gstack-decision-search new file mode 100755 index 0000000..2b81880 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-decision-search @@ -0,0 +1,108 @@ +#!/usr/bin/env bun +/** + * gstack-decision-search — read active decisions (the curated "what did we decide" view). + * + * Usage: + * gstack-decision-search [--query KW] [--scope repo|branch|issue] + * [--branch B] [--issue I] [--recent N] [--all] [--json] + * [--semantic] + * + * Reads the BOUNDED active snapshot (decisions.active.json) — O(active), not a full + * history scan — and rebuilds it from the event log if missing. Scope-filtered to the + * current branch/issue context (recency != relevance). NON-INTERACTIVE. `--all` shows + * superseded decisions too (from the full log). Exit 0 silently when there are none. + * + * `--semantic` (with `--query`) appends an OPTIONAL "related from memory" block from + * gbrain semantic recall. It is a pure enhancement: when gbrain is off/unconfigured/ + * empty it degrades silently to the reliable file results above. The reliable path + * never loads gbrain code (the semantic module is imported lazily only here). + */ + +import { existsSync } from "fs"; +import { + decisionPaths, + readSnapshot, + rebuildSnapshot, + readEvents, + filterByScope, + datamark, + type ActiveDecision, +} from "../lib/gstack-decision"; +import { resolveSlug, gitBranch, flagValue } from "../lib/bin-context"; + +const HERE = import.meta.dir; +const args = process.argv.slice(2); + +const slug = resolveSlug(`${HERE}/gstack-slug`); +const paths = decisionPaths(slug); +const queryRaw = flagValue(args, "--query"); +const query = queryRaw?.toLowerCase(); +const scope = flagValue(args, "--scope"); +const branch = flagValue(args, "--branch") ?? gitBranch(); +const issue = flagValue(args, "--issue"); +const recentRaw = flagValue(args, "--recent"); +const recent = recentRaw ? parseInt(recentRaw, 10) : undefined; +const showAll = args.includes("--all"); +const asJson = args.includes("--json"); +const semantic = args.includes("--semantic"); + +let rows: ActiveDecision[]; +if (showAll) { + // --all includes SUPERSEDED decisions (history), but NEVER redacted ones — a redact + // is an expunge, so it must remove the text from every read path, not just active. + const events = readEvents(paths); + const redacted = new Set( + events.filter((e) => e.kind === "redact" && e.supersedes).map((e) => e.supersedes as string), + ); + rows = events.filter((e): e is ActiveDecision => e.kind === "decide" && !redacted.has(e.id)); +} else { + rows = readSnapshot(paths); + // Rebuild only when a snapshot is absent but a log exists (don't write a snapshot + // into a nonexistent store on an empty read — just return nothing). + if (!rows.length && existsSync(paths.log)) rows = rebuildSnapshot(paths); +} + +rows = filterByScope(rows, { branch, issue }); +if (scope) rows = rows.filter((d) => d.scope === scope); +if (query) { + rows = rows.filter((d) => + [d.decision, d.rationale, d.alternatives_considered] + .filter((s): s is string => typeof s === "string") + .some((s) => s.toLowerCase().includes(query)), + ); +} +rows.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); // newest first +if (recent && recent > 0) rows = rows.slice(0, recent); + +if (asJson) { + // --json stays reliable-only (semantic recall is a human-facing supplement). + console.log(JSON.stringify(rows)); + process.exit(0); +} + +for (const d of rows) { + // Datamark all stored free-text (decision, rationale, branch/issue) — it lands in + // agent context via Context Recovery, so treat it as DATA, not instructions. + const branchTag = d.branch ? `:${datamark(d.branch)}` : ""; + const issueTag = d.issue ? `:${datamark(d.issue)}` : ""; + const scopeTag = d.scope === "repo" ? "" : ` [${d.scope}${branchTag}${issueTag}]`; + console.log(`- ${datamark(d.decision ?? "")}${scopeTag} (${d.source}, ${d.date.slice(0, 10)})`); + if (d.rationale) console.log(` why: ${datamark(d.rationale)}`); +} + +// OPTIONAL gbrain enhancement. Lazy import so the reliable path above never loads +// gbrain code. Degrades silently: null (gbrain off) or [] (nothing found) leaves the +// reliable results above as the answer. +if (semantic && queryRaw) { + const { semanticRecall } = await import("../lib/gstack-decision-semantic"); + const hits = semanticRecall(queryRaw); + if (hits && hits.length) { + console.log("\nRelated from memory (gbrain semantic recall):"); + for (const h of hits) { + // gbrain hits are EXTERNAL corpus content — datamark slug + snippet too so they + // can't spoof role markers / fences when printed into agent context. + const snip = datamark(h.snippet.length > 100 ? `${h.snippet.slice(0, 100)}…` : h.snippet); + console.log(` [${h.score.toFixed(2)}] ${datamark(h.slug)}: ${snip}`); + } + } +} diff --git a/.agents/skills/gstack/bin/gstack-detach b/.agents/skills/gstack/bin/gstack-detach new file mode 100755 index 0000000..101e86e --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-detach @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""gstack-detach — run a long agent job (evals, benchmarks, syncs) robustly. + +Agent-launched long jobs on a shared dev box keep dying to environmental +killers. This tool bakes in the fixes so gstack (and every gstack user) runs +them properly: + + * SIGTERM-proof: fork + setsid puts the job in its OWN session, so the + harness's "polite quit" SIGTERM to the launching process group can't reach + it (observed: `script "test:gate" was terminated by signal SIGTERM`). + * No idle-sleep death (macOS): wraps the command in `caffeinate -i`. + * No cross-worktree API saturation: `--lock NAME` takes a machine-wide + advisory lock so concurrent Conductor worktrees SERIALIZE their eval runs + instead of saturating the shared model API (which mass-times-out E2E suites). + * No shared-/tmp collision: a run-scoped log path by default + (~/.gstack-dev/eval-runs/

] [--pinned-commit ] [--dry-run] +# +# D5 detect-first: before cloning anywhere, probe likely pre-existing +# locations (~/git/gbrain and ~/gbrain) and reuse a working clone if one +# exists. Falls back to a fresh clone of the pinned commit at ~/gbrain +# (override with GBRAIN_INSTALL_DIR or --install-dir). +# +# D19 PATH-shadowing: after `bun link`, compare `gbrain --version` output +# to the install-dir's package.json version. On mismatch, abort with an +# actionable error listing every gbrain on PATH. Never "silently fixes" +# PATH; setup skills should refuse broken environments. +# +# Prerequisites (checked before doing anything): +# - bun (install: curl -fsSL https://bun.sh/install | bash) +# - git +# - network reachability to https://github.com +# +# gbrain installs at the latest default-branch HEAD by default — the hard pin +# was removed in #1744 (it had drifted ~23 versions behind). Pass +# --pinned-commit to install a specific commit for reproducibility. A +# minimum-version floor (MIN_GBRAIN_VERSION) hard-fails the install when the +# resulting gbrain is too old for gstack's sync integration, and a fast +# `gbrain doctor` self-test hard-fails a broken install when gbrain is already +# configured. This keeps the version gate that the pin used to provide without +# freezing users 23 releases behind. +# +# Env: +# GBRAIN_INSTALL_DIR — override default install path (~/gbrain) +# +# Exit codes: +# 0 — success (or --dry-run printed the plan) +# 2 — prerequisite missing or invalid argument +# 3 — post-install validation failed (PATH shadow, broken binary, etc.) +set -euo pipefail + +# --- defaults --- +# No version pin by default — install the latest default-branch HEAD (#1744). +# --pinned-commit overrides for reproducibility. +PINNED_COMMIT="" +PINNED_TAG="" +# Minimum gbrain version gstack's integration is known to work with. The +# `sources list --json` wrapped-object shape + federated sources landed by 0.20; +# older predates the surface gstack drives. Hard-fail below this floor (#1744). +MIN_GBRAIN_VERSION="0.20.0" +GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git" +DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}" +INSTALL_DIR="$DEFAULT_INSTALL_DIR" +DRY_RUN=false +VALIDATE_ONLY=false + +die() { echo "gstack-gbrain-install: $*" >&2; exit 2; } +fail() { echo "gstack-gbrain-install: $*" >&2; exit 3; } +log() { echo "gstack-gbrain-install: $*"; } + +# --- parse args --- +while [ $# -gt 0 ]; do + case "$1" in + --install-dir) INSTALL_DIR="$2"; shift 2 ;; + --pinned-commit) PINNED_COMMIT="$2"; PINNED_TAG=""; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --validate-only) VALIDATE_ONLY=true; shift ;; + --help|-h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown flag: $1" ;; + esac +done + +# --- prerequisites --- +check_prereq() { + local bin="$1" + local hint="$2" + if ! command -v "$bin" >/dev/null 2>&1; then + fail "required tool '$bin' not found. $hint" + fi +} + +if ! $VALIDATE_ONLY; then + check_prereq bun "Install: curl -fsSL https://bun.sh/install | bash" + check_prereq git "Install: xcode-select --install (macOS) or your package manager" + + # GitHub reachability — fail fast if offline rather than hanging `git clone`. + # --max-time 10, --head (no body), quiet. Status code 200-4xx means we reached + # the server (even 404 is reachability proof). + # + # Skipped under --dry-run: a dry run prints a plan and exits without ever + # cloning, so requiring the network buys nothing and costs a real failure mode. + # It made `--dry-run` fail (exit 3, "cannot reach https://github.com") whenever + # the curl lost a race for sockets/DNS — reproducible at ~15% by running 60 + # dry-runs concurrently, and the cause of intermittent red in the D5 tests, + # which call this exact path. + if ! $DRY_RUN && ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then + fail "cannot reach https://github.com. Check your network and try again." + fi +fi + +# --- D5 detect-first: probe common locations before cloning fresh --- +# Accept any directory that looks like a gbrain clone: has package.json +# with name "gbrain" and a `bin.gbrain` entry. Don't accept version mismatches +# here — we'll let bun link run and then D19-validate. +is_valid_clone() { + local dir="$1" + [ -d "$dir" ] || return 1 + [ -f "$dir/package.json" ] || return 1 + local name + name=$(jq -r '.name // empty' "$dir/package.json" 2>/dev/null || true) + [ "$name" = "gbrain" ] || return 1 + local bin + bin=$(jq -r '.bin.gbrain // empty' "$dir/package.json" 2>/dev/null || true) + [ -n "$bin" ] || return 1 + return 0 +} + +DETECTED_CLONE="" +if ! $VALIDATE_ONLY; then + for candidate in "$HOME/git/gbrain" "$HOME/gbrain" "$INSTALL_DIR"; do + if is_valid_clone "$candidate"; then + DETECTED_CLONE="$candidate" + break + fi + done +fi + +if $VALIDATE_ONLY; then + log "validate-only mode: skipping detect + clone + install + link" +elif [ -n "$DETECTED_CLONE" ]; then + log "detected existing gbrain clone at $DETECTED_CLONE — reusing" + INSTALL_DIR="$DETECTED_CLONE" +else + # Fresh clone path. + if $DRY_RUN; then + log "DRY RUN: would clone $GBRAIN_REPO_URL ${PINNED_COMMIT:+@ $PINNED_COMMIT }→ $INSTALL_DIR (latest HEAD unless --pinned-commit)" + exit 0 + fi + if [ -d "$INSTALL_DIR" ]; then + fail "install dir $INSTALL_DIR exists but is not a valid gbrain clone. Remove it or pass --install-dir ." + fi + log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR" + git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR" + if [ -n "$PINNED_COMMIT" ]; then + ( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" ) + log "checked out pinned commit $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}" + else + log "installed latest gbrain (default-branch HEAD)" + fi +fi + +if $DRY_RUN; then + log "DRY RUN: would run bun install + bun link in $INSTALL_DIR" + exit 0 +fi + +# --- install + link --- +# On Windows MSYS/Cygwin shells, bun's postinstall scripts (notably gbrain's +# native-bindings setup) fail to parse path arguments correctly and abort +# `bun install` with a non-zero exit. The package itself installs fine +# without scripts, so detect Windows and pass --ignore-scripts there. The +# `bun link` step below is unaffected. +IS_WINDOWS=0 +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; +esac + +if ! $VALIDATE_ONLY; then + if [ "$IS_WINDOWS" -eq 1 ]; then + log "running bun install --ignore-scripts in $INSTALL_DIR (Windows shell detected)" + ( cd "$INSTALL_DIR" && bun install --silent --ignore-scripts ) + else + log "running bun install in $INSTALL_DIR" + ( cd "$INSTALL_DIR" && bun install --silent ) + fi + log "running bun link in $INSTALL_DIR" + ( cd "$INSTALL_DIR" && bun link --silent ) +fi + +# #2487: an npm-installed bun (`npm i -g bun`) puts a POSIX script + .cmd/.ps1 +# shims on %PATH% but never bun.exe — and the gbrain.exe shim that `bun link` +# generates resolves bun.exe SPECIFICALLY. Link "succeeds", then every gbrain +# call dies with bun's misleading "bun is not installed in %PATH%" (suggesting +# a second parallel bun install). Detect the condition and name the real fix: +# bun's own process.execPath IS the hidden bun.exe. +_bun_exe_hint() { + [ "$IS_WINDOWS" -eq 1 ] || return 0 + command -v bun.exe >/dev/null 2>&1 && return 0 + local real_bun + real_bun=$(bun -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true) + echo " detected: bun was installed via npm — bun.exe is NOT on %PATH%, and the gbrain.exe shim needs it." >&2 + if [ -n "$real_bun" ]; then + echo " fix: add bun.exe's directory to PATH (persist it in your shell profile):" >&2 + echo " export PATH=\"$(dirname "$real_bun"):\$PATH\"" >&2 + else + echo " fix: install bun via the official installer (https://bun.sh) or add the directory containing bun.exe to %PATH%." >&2 + fi +} + +# --- D19 PATH-shadowing validation --- +# Read the version from the install-dir's package.json; compare to +# `gbrain --version`. If they disagree, PATH is returning a DIFFERENT +# gbrain than the one we just linked. Fail hard with remediation. +expected_version=$(jq -r '.version // empty' "$INSTALL_DIR/package.json" 2>/dev/null || true) +if [ -z "$expected_version" ]; then + fail "cannot read version from $INSTALL_DIR/package.json (install may be broken)" +fi + +if ! command -v gbrain >/dev/null 2>&1; then + _bun_exe_hint + fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH." +fi + +actual_version=$(gbrain --version 2>/dev/null | head -1 | awk '{print $NF}' | tr -d '[:space:]' || true) +if [ -z "$actual_version" ]; then + _bun_exe_hint + fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken." +fi + +# Tolerate a leading "v" (gbrain may print either "0.18.2" or "v0.18.2"). +expected_norm="${expected_version#v}" +actual_norm="${actual_version#v}" + +if [ "$actual_norm" != "$expected_norm" ]; then + echo "" >&2 + echo "gstack-gbrain-install: PATH SHADOWING DETECTED" >&2 + echo "" >&2 + echo " We just linked gbrain $expected_version from $INSTALL_DIR," >&2 + echo " but PATH is returning gbrain $actual_version." >&2 + echo "" >&2 + echo " All gbrain binaries on PATH:" >&2 + type -a gbrain 2>&1 | sed 's/^/ /' >&2 || true + echo "" >&2 + echo " Fix one of the following, then re-run /setup-gbrain:" >&2 + echo " a) rm the shadowing binary: rm \$(which gbrain)" >&2 + echo " b) prepend ~/.bun/bin to PATH in your shell rc" >&2 + echo " c) point GBRAIN_INSTALL_DIR at the shadowing binary's install dir" >&2 + echo "" >&2 + exit 3 +fi + +log "installed gbrain $actual_version from $INSTALL_DIR" + +# --- minimum-version floor (#1744) --- +# Unpinning means new installs track gbrain HEAD. Hard-fail if the resulting +# version is below the floor gstack's sync integration needs — same exit-3 posture +# as the PATH-shadow / version-mismatch failures above. A warning here is exactly +# how the data-loss class slipped through, so this gate fails closed. +version_lt() { + # 0 (true) when $1 < $2 by version sort; equal versions are NOT less-than. + [ "$1" = "$2" ] && return 1 + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ] +} +if version_lt "$actual_norm" "$MIN_GBRAIN_VERSION"; then + echo "" >&2 + echo "gstack-gbrain-install: gbrain $actual_version is below the minimum gstack-tested version ($MIN_GBRAIN_VERSION)." >&2 + echo " gstack's sync integration needs the v0.20+ source/list surface." >&2 + echo " Fix: update the gbrain clone at $INSTALL_DIR to a newer release (git pull), then" >&2 + echo " re-run /setup-gbrain. Or pass --pinned-commit to install a specific newer commit." >&2 + echo "" >&2 + exit 3 +fi + +# --- functional self-test when gbrain is already configured (#1744) --- +# When a brain config exists (re-install / detected clone), run a fast doctor as +# a hard gate so a broken gbrain is caught at setup, not at data-loss time. +# Pre-init installs skip this (config not written yet); the full +# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`. +# #2521: GBRAIN_HOME is a PARENT dir per gbrain's configDir() contract — +# gbrain appends `.gbrain` itself, so the config lives at +# $GBRAIN_HOME/.gbrain/config.json (or ~/.gbrain/config.json when unset). +if [ -n "${GBRAIN_HOME:-}" ]; then + _GBRAIN_HOME_CHECK="$GBRAIN_HOME/.gbrain" +else + _GBRAIN_HOME_CHECK="$HOME/.gbrain" +fi +if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then + if ! gbrain doctor --fast >/dev/null 2>&1; then + echo "" >&2 + echo "gstack-gbrain-install: gbrain $actual_version installed but 'gbrain doctor --fast' failed." >&2 + echo " Refusing to leave a broken gbrain in place. Run 'gbrain doctor' to see what's wrong," >&2 + echo " fix it, then re-run /setup-gbrain." >&2 + echo "" >&2 + exit 3 + fi + log "gbrain doctor --fast passed" +fi + +# v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts +# may skip artifacts gbrain needs at runtime, especially on Windows +# MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above +# already confirmed the binary runs; this second probe checks that the +# subcommand surface is reachable (`sources` is the entry point the sync +# stage hits first). If the probe fails, we warn but don't exit non-zero — +# the user may still be able to use other commands. +if ! gbrain sources --help >/dev/null 2>&1; then + echo "" >&2 + echo "gstack-gbrain-install: WARNING — gbrain installed but 'gbrain sources --help' did not exit 0." >&2 + if [ "$IS_WINDOWS" -eq 1 ]; then + echo " Windows shells skip bun postinstall scripts; some gbrain features may need native build tools." >&2 + echo " If /sync-gbrain fails to find subcommands, install gbrain from a non-MSYS shell," >&2 + echo " or run: cd $INSTALL_DIR && bun install (without --ignore-scripts)" >&2 + else + echo " This may be a transient gbrain CLI issue or a missing native dependency." >&2 + echo " If /sync-gbrain fails, re-run: cd $INSTALL_DIR && bun install" >&2 + fi + echo "" >&2 +fi + +echo "" +if [ -n "${VOYAGE_API_KEY:-}" ]; then + echo "Next: gbrain init --pglite --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024" + echo " (or run /setup-gbrain for the full setup flow)" +else + echo "Next: gbrain init --pglite (or run /setup-gbrain for the full setup flow)" + echo "" + echo "Tip: set VOYAGE_API_KEY before init to use voyage-code-3 (best embedding" + echo "model for code retrieval on Voyage). Without it, gbrain falls back to its" + echo "auto-selected provider (OpenAI when OPENAI_API_KEY is set, etc.)." +fi diff --git a/.agents/skills/gstack/bin/gstack-gbrain-lib.sh b/.agents/skills/gstack/bin/gstack-gbrain-lib.sh new file mode 100755 index 0000000..b89cce2 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-lib.sh @@ -0,0 +1,115 @@ +# gstack-gbrain-lib.sh — shared helpers for setup-gbrain bin scripts. +# +# This file is NOT executable; source it: +# +# . "$(dirname "$0")/gstack-gbrain-lib.sh" +# +# Provides: +# read_secret_to_env [--echo-redacted ] +# — Read a secret from stdin into the named env var without echoing +# to the terminal. On SIGINT/SIGTERM/EXIT, restores terminal echo so +# future keystrokes are visible. Optionally emits a redacted preview +# of what was read so the user can visually confirm they pasted the +# right thing. +# +# stdin handling: when stdin is a TTY, stty -echo suppresses echo +# while the user types. When stdin is piped (automated tests), the +# stty calls are skipped — piping into `read` is already invisible. +# +# Var name must match [A-Z_][A-Z0-9_]* to prevent injection via +# `read -r "$varname"` expansion. Invalid names abort. +# +# Exported after read so sub-processes inherit the secret. Caller +# is responsible for `unset ` when done. +# +# Load-bearing for D3-eng (shared secret helper across PAT + URL paste), +# D10 (env-var handoff, never argv), D11 (PAT scope disclosure + SIGINT +# restore), D16 (pooler URL paste hygiene with redacted preview). + +# _gstack_gbrain_validate_varname — returns 0 if usable, 2 otherwise. +# `local LC_ALL=C` is load-bearing twice over: +# 1. In many macOS shells the default locale (e.g. en_US.UTF-8) makes `case` +# glob brackets like `[A-Z]` match lowercase letters too. Without the +# LC_ALL=C pin, names like `lower-case` pass validation and then trip +# `printf -v "$varname"` and `export "$varname"` with "not a valid +# identifier" errors the caller can't easily distinguish from other +# failures. +# 2. `local` is required because this file is documented as a sourced helper +# (see header), so a bare `LC_ALL=C` would mutate the caller's locale for +# the rest of the process — silently affecting downstream `sort`, `tr`, +# and any locale-aware glob in the same shell. +# Together they give ASCII-only bracket semantics on both macOS and Linux +# (matching the documented `[A-Z_][A-Z0-9_]*` contract) without leaking. +_gstack_gbrain_validate_varname() { + local name="$1" + local LC_ALL=C + case "$name" in + [A-Z_][A-Z0-9_]*) return 0 ;; + *) return 2 ;; + esac +} + +read_secret_to_env() { + local varname="" prompt="" redact_expr="" + # Parse leading positional args (varname, prompt), then optional flags. + if [ $# -lt 2 ]; then + echo "read_secret_to_env: usage: read_secret_to_env [--echo-redacted ]" >&2 + return 2 + fi + varname="$1"; shift + prompt="$1"; shift + while [ $# -gt 0 ]; do + case "$1" in + --echo-redacted) redact_expr="$2"; shift 2 ;; + *) echo "read_secret_to_env: unknown flag: $1" >&2; return 2 ;; + esac + done + + if ! _gstack_gbrain_validate_varname "$varname"; then + echo "read_secret_to_env: invalid var name '$varname' (must match [A-Z_][A-Z0-9_]*)" >&2 + return 2 + fi + + # stty manipulation only makes sense when stdin is a terminal. In CI / + # test / piped contexts we skip it — piped input doesn't echo anyway. + local is_tty=false + if [ -t 0 ]; then is_tty=true; fi + + if $is_tty; then + # Save current stty state; restore on any exit path. + local saved_stty + saved_stty=$(stty -g 2>/dev/null || echo "") + # shellcheck disable=SC2064 + trap "stty '$saved_stty' 2>/dev/null; printf '\n' >&2" INT TERM EXIT + stty -echo 2>/dev/null || true + fi + + # Prompt on stderr so the caller can capture stdout cleanly. + printf '%s' "$prompt" >&2 + + # Read one line from stdin. `read -r` returns nonzero on EOF-without- + # newline but still populates `value` with whatever it saw — we want that + # content, so don't clear on failure. + local value="" + IFS= read -r value || true + + if $is_tty; then + stty "$saved_stty" 2>/dev/null || true + trap - INT TERM EXIT + printf '\n' >&2 + fi + + # Assign + export to the named variable. + printf -v "$varname" '%s' "$value" + # shellcheck disable=SC2163 + export "$varname" + + # Optional redacted preview after successful read. + if [ -n "$redact_expr" ] && [ -n "$value" ]; then + local preview + preview=$(printf '%s' "$value" | sed "$redact_expr" 2>/dev/null || true) + if [ -n "$preview" ]; then + printf 'Got: %s\n' "$preview" >&2 + fi + fi +} diff --git a/.agents/skills/gstack/bin/gstack-gbrain-mcp-verify b/.agents/skills/gstack/bin/gstack-gbrain-mcp-verify new file mode 100755 index 0000000..b3459ca --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-mcp-verify @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# gstack-gbrain-mcp-verify — probe a remote gbrain MCP endpoint. +# +# Usage: +# GBRAIN_MCP_TOKEN= gstack-gbrain-mcp-verify +# +# Output (always valid JSON): +# { +# "status": "success" | "network" | "auth" | "malformed", +# "server_name": "gbrain" | null, +# "server_version": "0.26.8" | null, +# "error_class": "NETWORK" | "AUTH" | "MALFORMED" | null, +# "error_text": "" | null, +# "sources_add_url_supported": true | false, +# "raw_initialize_body": "" | null +# } +# +# Token is consumed from the GBRAIN_MCP_TOKEN env var, never argv. Prevents +# shell-history / `ps` exposure of the bearer. +# +# Three error classes: +# NETWORK — DNS / TCP / no HTTP response +# AUTH — 401, 403, or 500 with stale-token-shaped body +# MALFORMED — 2xx but missing serverInfo, OR `Not Acceptable` (the dual +# Accept-header gotcha) +# +# `sources_add_url_supported` probes capability via tools/list — true iff the +# remote exposes `mcp__gbrain__sources_add` (gbrain hasn't shipped this as +# of v0.26.x; field is forward-compatible). +# +# Exit codes: 0 on success, 1 on classified failure, 2 on usage error. +set -euo pipefail + +die_usage() { + echo "Usage: GBRAIN_MCP_TOKEN= gstack-gbrain-mcp-verify " >&2 + exit 2 +} + +[ $# -eq 1 ] || die_usage +URL="$1" +[ -n "${GBRAIN_MCP_TOKEN:-}" ] || { echo "gstack-gbrain-mcp-verify: GBRAIN_MCP_TOKEN env var required" >&2; exit 2; } + +command -v curl >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: curl is required" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: jq is required (brew install jq)" >&2; exit 2; } + +# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed. +. "$(cd "$(dirname "$0")" && pwd)/gstack-egress-lib.sh" +MCP_HOST=$(echo "$URL" | sed -E 's|^[a-z]+://([^/]+).*|\1|') + +emit() { + # emit + jq -n \ + --arg status "$1" \ + --arg server_name "${2:-}" \ + --arg server_version "${3:-}" \ + --arg error_class "${4:-}" \ + --arg error_text "${5:-}" \ + --argjson url_supported "${6:-false}" \ + --arg raw "${7:-}" \ + '{ + status: $status, + server_name: (if $server_name == "" then null else $server_name end), + server_version: (if $server_version == "" then null else $server_version end), + error_class: (if $error_class == "" then null else $error_class end), + error_text: (if $error_text == "" then null else $error_text end), + sources_add_url_supported: $url_supported, + raw_initialize_body: (if $raw == "" then null else $raw end) + }' +} + +# JSON-RPC initialize body. Both `application/json` AND `text/event-stream` +# in Accept — the MCP server returns 406 Not Acceptable without both. The +# transcript that motivated this script hit that exact failure. +INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"gstack-mcp-verify","version":"1"}}}' + +# Capture HTTP code + body in one pass; --max-time 10 caps total wall time. +TMPBODY=$(mktemp -t gstack-mcp-verify.XXXXXX) +trap 'rm -f "$TMPBODY"' EXIT + +# Receipted fail-closed: the payload file is hashed and handed to curl as +# the exact wire bytes. A refused receipt never hits the network — it lands +# in the NETWORK class below (curl never ran, no HTTP code). +INIT_PAYLOAD=$(mktemp -t gstack-mcp-init.XXXXXX) +printf '%s' "$INIT_BODY" > "$INIT_PAYLOAD" +set +e +HTTP_CODE=$(_receipted_curl closed gbrain-mcp-verify "$MCP_HOST" mcp-initialize-probe "user-invoked mcp verify" "$INIT_PAYLOAD" \ + curl -s -o "$TMPBODY" -w '%{http_code}' \ + --max-time 10 \ + -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \ + "$URL") +CURL_EXIT=$? +set -e + +BODY=$(cat "$TMPBODY" 2>/dev/null || echo "") + +# --- NETWORK class: curl exited nonzero, no HTTP response --- +if [ "$CURL_EXIT" -ne 0 ] || [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" = "000" ]; then + HOST=$(echo "$URL" | sed -E 's|^https?://([^/:]+).*|\1|') + emit "network" "" "" "NETWORK" "check Tailscale/DNS to ${HOST} (curl exit=${CURL_EXIT})" false "$BODY" + exit 1 +fi + +# --- AUTH class: 401, 403, or 500 with stale-token-shaped body --- +case "$HTTP_CODE" in + 401|403) + emit "auth" "" "" "AUTH" "rotate token on the brain host, re-run /setup-gbrain (HTTP $HTTP_CODE)" false "$BODY" + exit 1 + ;; + 500) + if echo "$BODY" | grep -qiE '"(error_description|message)":[[:space:]]*"[^"]*(auth|token|unauthorized)' 2>/dev/null; then + emit "auth" "" "" "AUTH" "rotate token on the brain host, re-run /setup-gbrain (HTTP 500 stale-token shape)" false "$BODY" + exit 1 + fi + ;; +esac + +# Anything not 2xx that isn't auth-shaped → MALFORMED with raw HTTP code. +case "$HTTP_CODE" in + 2*) ;; + *) + emit "malformed" "" "" "MALFORMED" "server returned HTTP $HTTP_CODE; verify URL + version compatibility" false "$BODY" + exit 1 + ;; +esac + +# --- 2xx path: body may be JSON or SSE-wrapped JSON. Strip SSE if present. --- +# MCP servers return SSE format: `event: message\ndata: {...}\n\n`. Extract +# just the JSON payload from the data: line, falling back to the body as-is. +if echo "$BODY" | head -1 | grep -q '^event:'; then + JSON_BODY=$(echo "$BODY" | sed -n 's/^data: //p' | head -1) +else + JSON_BODY="$BODY" +fi + +# `Not Acceptable` is a JSON-RPC error from the MCP server itself, returned +# with HTTP 200 if the SSE Accept header was missing. Detect it explicitly. +if echo "$JSON_BODY" | jq -e '.error.message | test("[Nn]ot [Aa]cceptable")' >/dev/null 2>&1; then + emit "malformed" "" "" "MALFORMED" "Accept-header gotcha: pass both 'application/json' AND 'text/event-stream'" false "$BODY" + exit 1 +fi + +SERVER_NAME=$(echo "$JSON_BODY" | jq -r '.result.serverInfo.name // empty' 2>/dev/null) +SERVER_VERSION=$(echo "$JSON_BODY" | jq -r '.result.serverInfo.version // empty' 2>/dev/null) + +if [ -z "$SERVER_NAME" ] || [ -z "$SERVER_VERSION" ]; then + emit "malformed" "" "" "MALFORMED" "server may be on a newer gbrain version; missing result.serverInfo. Verify with: curl -H 'Accept: application/json, text/event-stream'" false "$BODY" + exit 1 +fi + +# --- Capability probe: tools/list to detect sources_add --- +# Best-effort. A failure here doesn't fail the verify; we just default +# sources_add_url_supported=false. Future gbrain versions that ship +# mcp__gbrain__sources_add will flip this true and gstack-artifacts-init +# will print the one-liner form instead of the clone-then-path form. +URL_SUPPORTED=false +TOOLS_BODY_FILE=$(mktemp -t gstack-mcp-tools.XXXXXX) +TOOLS_REQ='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' + +# Receipted fail-closed like the initialize probe. A refused receipt skips +# the probe (nonzero TOOLS_EXIT) and the field stays false — best-effort. +TOOLS_PAYLOAD=$(mktemp -t gstack-mcp-tools-req.XXXXXX) +printf '%s' "$TOOLS_REQ" > "$TOOLS_PAYLOAD" +set +e +_receipted_curl closed gbrain-mcp-verify "$MCP_HOST" mcp-tools-list-probe "user-invoked mcp verify" "$TOOLS_PAYLOAD" \ + curl -s -o "$TOOLS_BODY_FILE" \ + --max-time 10 \ + -X POST \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \ + "$URL" >/dev/null 2>&1 +TOOLS_EXIT=$? +set -e + +if [ "$TOOLS_EXIT" -eq 0 ]; then + TOOLS_BODY=$(cat "$TOOLS_BODY_FILE" 2>/dev/null || echo "") + if echo "$TOOLS_BODY" | head -1 | grep -q '^event:'; then + TOOLS_JSON=$(echo "$TOOLS_BODY" | sed -n 's/^data: //p' | head -1) + else + TOOLS_JSON="$TOOLS_BODY" + fi + if echo "$TOOLS_JSON" | jq -e '.result.tools[] | select(.name | test("sources_add"))' >/dev/null 2>&1; then + URL_SUPPORTED=true + fi +fi +rm -f "$TOOLS_BODY_FILE" + +emit "success" "$SERVER_NAME" "$SERVER_VERSION" "" "" "$URL_SUPPORTED" "$BODY" +exit 0 diff --git a/.agents/skills/gstack/bin/gstack-gbrain-repo-policy b/.agents/skills/gstack/bin/gstack-gbrain-repo-policy new file mode 100755 index 0000000..f1204b1 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-repo-policy @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# gstack-gbrain-repo-policy — per-remote trust tier for gbrain repo ingest. +# +# Usage: +# gstack-gbrain-repo-policy get [] +# Print the tier for the given remote, or the current repo's origin +# if no URL is passed. Exits 0 with one of: read-write, read-only, +# deny, unset. +# +# gstack-gbrain-repo-policy get --batch +# Read remote URLs from stdin (one per line); print one tier per line +# in input order: read-write, read-only, deny, or none (no entry / no +# store). A corrupt store is a hard error (exit 2), NEVER quarantined: +# batch callers are unattended ingest gates that must fail closed +# rather than bypass a set policy. +# +# gstack-gbrain-repo-policy set +# Persist a tier for the given remote. Exits 0 on success. +# +# gstack-gbrain-repo-policy list +# Print every entry as "\t", sorted by key. +# +# gstack-gbrain-repo-policy normalize +# Print the normalized (canonical) key for a given remote URL. +# Use this when other skills or tests need the same collapsing logic. +# +# gstack-gbrain-repo-policy --help +# +# Storage: +# ~/.gstack/gbrain-repo-policy.json, mode 0600. +# +# File format: +# { +# "_schema_version": 2, +# "github.com/foo/bar": "read-write", +# "github.com/baz/qux": "deny" +# } +# +# Tier semantics: +# read-write — agent may search AND write new pages from this repo. +# read-only — agent may search but NEVER write pages from this repo. +# (Enforced at the caller level; this binary just stores the +# decision.) +# deny — no gbrain interaction at all. +# +# Legacy migration: +# On any read of a file missing `_schema_version` (or with version < 2), +# legacy `allow` values are atomically rewritten to `read-write`, and +# `_schema_version: 2` is added. Log line emitted on stderr when the +# migration actually changes anything. Idempotent: running twice is safe. +# +# Env: +# GSTACK_HOME — override ~/.gstack state directory (aligns with other +# gstack-* bins; used heavily in tests). +set -euo pipefail + +STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" +POLICY_FILE="$STATE_DIR/gbrain-repo-policy.json" +SCHEMA_VERSION=2 + +die() { echo "gstack-gbrain-repo-policy: $*" >&2; exit 2; } + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + die "jq is required. Install with: brew install jq" + fi +} + +# normalize — canonical form: lowercase host + path, no protocol, +# no userinfo, no trailing .git or /. SSH shorthand (git@host:path) collapses +# to the same key as https://host/path. +normalize() { + local url="$1" + [ -z "$url" ] && { echo ""; return 0; } + # Strip protocol:// + url="${url#*://}" + # Strip userinfo (git@, user:password@, etc.) — everything up to and + # including the first @ iff an @ appears before the first / or :. + case "$url" in + *@*) + local before_at="${url%%@*}" + case "$before_at" in + */*|*:*) : ;; # @ is in the path, not userinfo — leave it + *) url="${url#*@}" ;; + esac + ;; + esac + # SSH shorthand: github.com:foo/bar → github.com/foo/bar. Only when the + # hostname-part (before first /) contains a colon. sed is clearer than + # bash's `${var/:/\/}` which has tricky escaping. + local head="${url%%/*}" + case "$head" in + *:*) url=$(printf '%s' "$url" | sed 's|:|/|') ;; + esac + # Lowercase BEFORE the suffix strips so a `.GIT` suffix still strips — + # parity with lib/gstack-memory-helpers' canonicalizeRemote, which strips + # `.git` case-insensitively. GitHub and most hosts are case-insensitive on + # paths anyway; collapsing avoids duplicate entries for "Foo/Bar" vs + # "foo/bar". (Parity is pinned by test/gbrain-repo-policy-client.test.ts: + # a key set through THIS normalize must be found via the canonicalized form + # memory-ingest passes to `get --batch`.) + url=$(printf '%s' "$url" | tr '[:upper:]' '[:lower:]') + # Strip trailing slash(es) FIRST, so ".git/" still loses its suffix (same + # order as canonicalizeRemote — slash-first, then .git, then re-strip). + while [ "${url%/}" != "$url" ]; do url="${url%/}"; done + # Strip trailing .git + url="${url%.git}" + # Re-strip trailing slash(es): a path remote ending in a `.git` directory + # component ("/repo/.git") exposes a new trailing slash once .git is gone. + while [ "${url%/}" != "$url" ]; do url="${url%/}"; done + printf '%s\n' "$url" +} + +# ensure_file — create the policy file if missing, migrate if legacy. +# Emits the migration log line on stderr exactly once per run when a +# migration actually rewrites values. +ensure_file() { + require_jq + mkdir -p "$STATE_DIR" + + if [ ! -f "$POLICY_FILE" ]; then + # Fresh file — just the schema version, no entries. + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + return 0 + fi + + # File exists — validate, migrate if needed. + local raw + if ! raw=$(cat "$POLICY_FILE" 2>/dev/null); then + die "Cannot read $POLICY_FILE" + fi + + # Corrupt JSON → quarantine and start fresh. + if ! echo "$raw" | jq empty 2>/dev/null; then + local ts + ts=$(date +%Y%m%d-%H%M%S) + local quarantine="$POLICY_FILE.corrupt-$ts" + mv "$POLICY_FILE" "$quarantine" + echo "gstack-gbrain-repo-policy: corrupt policy file quarantined to $quarantine; starting fresh" >&2 + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + return 0 + fi + + # Check schema version. + local version + version=$(echo "$raw" | jq -r '._schema_version // 0') + if [ "$version" -ge "$SCHEMA_VERSION" ]; then + return 0 + fi + + # Migrate: rename `allow` → `read-write`, add _schema_version. + local allow_count migrated + allow_count=$(echo "$raw" | jq '[to_entries[] | select(.key != "_schema_version" and .value == "allow")] | length') + migrated=$(echo "$raw" | jq --argjson v "$SCHEMA_VERSION" ' + (to_entries | map( + if .key == "_schema_version" then empty + elif .value == "allow" then .value = "read-write" + else . + end + ) | from_entries) + {_schema_version: $v} + ') + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + printf '%s\n' "$migrated" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + if [ "$allow_count" -gt 0 ]; then + echo "[gstack-gbrain-repo-policy] Migrated $allow_count legacy allow entries to read-write" >&2 + fi +} + +# get --batch — bulk lookup for ingest gates. One URL per stdin line, one +# tier per stdout line, input order preserved. Reuses normalize() (the same +# code path single `get` uses) per line. Prints `none` where single `get` +# prints `unset` — batch consumers (lib/gbrain-repo-policy-client.ts) speak +# the RepoPolicyTierValue vocabulary directly. +# +# Corruption polarity differs from single `get` ON PURPOSE: interactive +# `get` quarantines a corrupt store and starts fresh because /setup-gbrain +# re-asks the user; a batch caller is an unattended ingest gate with nobody +# to re-ask, so silently quarantining would BYPASS a set deny policy. Batch +# fails hard (exit 2) instead and names the recovery path. +cmd_get_batch() { + require_jq + if [ ! -f "$POLICY_FILE" ]; then + # No store = no policy was ever set. Every URL is `none`; don't create + # the file just for a read (matches cmd_list). + while IFS= read -r url || [ -n "$url" ]; do + printf 'none\n' + done + return 0 + fi + if ! jq empty "$POLICY_FILE" 2>/dev/null; then + die "policy store $POLICY_FILE is corrupt (invalid JSON) — refusing batch read. Inspect with: gstack-gbrain-repo-policy list; re-run /setup-gbrain to rebuild the store." + fi + # Valid JSON from here, so ensure_file only performs the legacy + # allow → read-write migration (never the quarantine branch). + ensure_file + local url key + while IFS= read -r url || [ -n "$url" ]; do + key=$(normalize "$url") + if [ -z "$key" ]; then + printf 'none\n' + continue + fi + jq -r --arg key "$key" '.[$key] // "none"' "$POLICY_FILE" + done +} + +cmd_get() { + local url="${1:-}" + if [ "$url" = "--batch" ]; then + cmd_get_batch + return 0 + fi + if [ -z "$url" ]; then + url=$(git remote get-url origin 2>/dev/null || true) + if [ -z "$url" ]; then + echo "unset" + return 0 + fi + fi + local key + key=$(normalize "$url") + if [ -z "$key" ]; then + echo "unset" + return 0 + fi + ensure_file + jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE" +} + +cmd_set() { + local url="${1:-}" + local tier="${2:-}" + [ -z "$url" ] && die "usage: set " + [ -z "$tier" ] && die "usage: set " + case "$tier" in + read-write|read-only|deny) ;; + *) die "invalid tier '$tier' (must be one of: read-write, read-only, deny)" ;; + esac + local key + key=$(normalize "$url") + [ -z "$key" ] && die "cannot normalize remote URL: $url" + ensure_file + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + echo "Set $key → $tier" +} + +cmd_list() { + if [ ! -f "$POLICY_FILE" ]; then + # Nothing to list; don't create the file just for a read. + return 0 + fi + ensure_file + jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort +} + +cmd_normalize() { + local url="${1:-}" + [ -z "$url" ] && die "usage: normalize " + normalize "$url" +} + +case "${1:-}" in + get) shift; cmd_get "$@" ;; + set) shift; cmd_set "$@" ;; + list) shift; cmd_list "$@" ;; + normalize) shift; cmd_normalize "$@" ;; + --help|-h|help) sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//' ;; + "") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;; + *) die "unknown subcommand: $1" ;; +esac diff --git a/.agents/skills/gstack/bin/gstack-gbrain-source-wireup b/.agents/skills/gstack/bin/gstack-gbrain-source-wireup new file mode 100755 index 0000000..7947fd5 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-source-wireup @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +# gstack-gbrain-source-wireup — register the gstack brain repo as a gbrain +# federated source via `git worktree`, run an initial sync, hook into +# subsequent skill-end syncs. +# +# Replaces the v1.12.2.0 dead `consumers.json + ingest_url + /ingest-repo` +# wireup which depended on a gbrain HTTP endpoint that never shipped. +# +# Usage: +# gstack-gbrain-source-wireup [--strict] [--source-id ] [--no-pull] +# [--database-url ] +# gstack-gbrain-source-wireup --uninstall [--source-id ] +# [--database-url ] +# gstack-gbrain-source-wireup --probe +# gstack-gbrain-source-wireup --advance-only # daily unattended worktree advance (#2516) +# gstack-gbrain-source-wireup --help +# +# Exit codes: +# 0 — success, OR benign skip without --strict +# 1 — hard failure (gbrain or git op errored on a real call) +# 2 — missing prereqs (no gbrain >= 0.18.0, no .git or remote-file) +# 3 — source-id derivation failed in --uninstall, no fallback worked +# +# Env: +# GSTACK_HOME — override ~/.gstack (test harness) +# GSTACK_BRAIN_WORKTREE — override worktree path (default ~/.gstack-brain-worktree) +# GSTACK_BRAIN_SOURCE_ID — id override; --source-id flag takes precedence +# GSTACK_BRAIN_NO_SYNC — skip the gbrain sync step (tests; helper still +# ensures source registration) +# +# Defense against external rewrites of ~/.gbrain/config.json: +# At helper startup we capture the database URL ONCE — from --database-url, +# from GBRAIN_DATABASE_URL/DATABASE_URL env, or from ~/.gbrain/config.json — +# and export it as GBRAIN_DATABASE_URL for every child `gbrain` invocation. +# That env var overrides whatever's in config.json (per gbrain's loadConfig +# at src/core/config.ts:53), so a process that flips config.json mid-sync +# can't redirect us at a different brain mid-stream. +# +# Depends on: jq (transitive via gstack-gbrain-detect). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" +# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. +if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then + REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" +else + REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +fi +PLIST_PATH="$HOME/Library/LaunchAgents/com.gstack.brain-sync.plist" +GBRAIN_CONFIG="$HOME/.gbrain/config.json" + +# ---- arg parse ---- +MODE="wireup" +STRICT=0 +NO_PULL=0 +SOURCE_ID="" +DATABASE_URL_ARG="" + +while [ $# -gt 0 ]; do + case "$1" in + --uninstall) MODE="uninstall"; shift ;; + --probe) MODE="probe"; shift ;; + --advance-only) MODE="advance-only"; shift ;; + --strict) STRICT=1; shift ;; + --no-pull) NO_PULL=1; shift ;; + --source-id) SOURCE_ID="$2"; shift 2 ;; + --database-url) DATABASE_URL_ARG="$2"; shift 2 ;; + --help|-h) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +# ---- lock the database URL at startup ---- +# Precedence: --database-url flag > existing GBRAIN_DATABASE_URL/DATABASE_URL +# env > read once from ~/.gbrain/config.json. Whichever wins gets exported as +# GBRAIN_DATABASE_URL so every child `gbrain` invocation uses THAT brain even +# if config.json is rewritten by another process during the wireup. +_locked_url="" +if [ -n "$DATABASE_URL_ARG" ]; then + _locked_url="$DATABASE_URL_ARG" +elif [ -n "${GBRAIN_DATABASE_URL:-}" ]; then + _locked_url="$GBRAIN_DATABASE_URL" +elif [ -n "${DATABASE_URL:-}" ]; then + _locked_url="$DATABASE_URL" +elif [ -f "$GBRAIN_CONFIG" ]; then + # Python heredoc reads config.json. On JSON parse failure or any IO error, + # we WARN (not silently swallow) so the user knows the URL lock fell back + # to gbrain's own loadConfig (which would still read this same file). + _py_err=$(mktemp -t wireup-pyerr 2>/dev/null || mktemp /tmp/wireup-pyerr.XXXXXX) + _locked_url=$(GBRAIN_CONFIG_PATH="$GBRAIN_CONFIG" python3 -c ' +import json, os, sys +try: + c = json.load(open(os.environ["GBRAIN_CONFIG_PATH"])) + print(c.get("database_url","")) +except FileNotFoundError: + sys.exit(0) +except Exception as e: + print(f"config.json parse error: {e}", file=sys.stderr) + sys.exit(1) +' "$_py_err") || warn "could not read $GBRAIN_CONFIG ($(cat "$_py_err" 2>/dev/null)); URL not locked" + rm -f "$_py_err" 2>/dev/null +fi +if [ -n "$_locked_url" ]; then + export GBRAIN_DATABASE_URL="$_locked_url" +fi + +prefix() { sed 's/^/gstack-gbrain-source-wireup: /' >&2; } +warn() { echo "$*" | prefix; } +# die [exit_code]: warn with just the message, exit with code (default 1). +die() { warn "$1"; exit "${2:-1}"; } + +# Refuse to rm anything outside $HOME/. Defends against GSTACK_BRAIN_WORKTREE=/ +# or empty-string overrides that would otherwise have line 169 / 161 nuke the +# user's home or root. +safe_rm_worktree() { + local target="$1" + case "$target" in + "" | "/" | "/Users" | "/Users/" | "$HOME" | "$HOME/" ) + die "refusing to rm dangerous path: $target" 1 ;; + esac + case "$target" in + "$HOME"/*) rm -rf "$target" ;; + *) die "refusing to rm path outside \$HOME: $target" 1 ;; + esac +} + +# ---- source-id derivation (D6 multi-fallback) ---- +derive_source_id() { + if [ -n "$SOURCE_ID" ]; then + echo "$SOURCE_ID"; return 0 + fi + if [ -n "${GSTACK_BRAIN_SOURCE_ID:-}" ]; then + echo "$GSTACK_BRAIN_SOURCE_ID"; return 0 + fi + local remote_url="" + remote_url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null) || true + if [ -z "$remote_url" ] && [ -f "$REMOTE_FILE" ]; then + remote_url=$(head -1 "$REMOTE_FILE" 2>/dev/null | tr -d '[:space:]') + fi + [ -z "$remote_url" ] && return 3 + basename "$remote_url" .git \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' \ + | sed 's/--*/-/g; s/^-//; s/-$//' \ + | cut -c1-32 +} + +# ---- gbrain version gate ---- +gbrain_version_ok() { + if ! command -v gbrain >/dev/null 2>&1; then + return 1 + fi + local v + v=$(gbrain --version 2>/dev/null | awk '{print $2}') + [ -z "$v" ] && return 1 + # 0.18.0 minimum (gbrain sources shipped here). Put the floor first in stdin + # so equal or greater $v sorts to position 2 — head -1 == "0.18.0" iff $v >= floor. + [ "$(printf '0.18.0\n%s\n' "$v" | sort -V | head -1)" = "0.18.0" ] +} + +# ---- worktree management ---- +# A worktree is always created `--detach`ed at $GSTACK_HOME's HEAD. Detached +# because a branch (main) can only be checked out in ONE worktree, and the +# parent at $GSTACK_HOME already has it. To advance, we re-checkout the +# parent's current HEAD into the detached worktree. +_worktree_add_detached() { + local sha + sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || return 1 + git -C "$GSTACK_HOME" worktree prune 2>/dev/null || true + # Surface git errors via prefix so users see WHY the add failed (disk, perms, etc). + git -C "$GSTACK_HOME" worktree add --detach "$WORKTREE" "$sha" 2>&1 | prefix + return "${PIPESTATUS[0]}" +} + +ensure_worktree() { + if [ ! -d "$GSTACK_HOME/.git" ]; then + return 2 + fi + if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then + # already exists; advance the detached HEAD to parent's current HEAD + if [ "$NO_PULL" = "0" ]; then + local sha + sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || return 1 + # Surface checkout errors via prefix so users see WHY the advance failed + # (uncommitted changes in the detached worktree, ref ambiguity, etc). + ( cd "$WORKTREE" && git checkout --detach "$sha" 2>&1 | prefix; exit "${PIPESTATUS[0]}" ) || { + warn "worktree at $WORKTREE could not advance to $sha; resetting via remove + re-add" + git -C "$GSTACK_HOME" worktree remove --force "$WORKTREE" 2>/dev/null || safe_rm_worktree "$WORKTREE" + _worktree_add_detached || return 1 + } + fi + return 0 + fi + # Stray non-git dir? Remove first. + [ -e "$WORKTREE" ] && safe_rm_worktree "$WORKTREE" + _worktree_add_detached || return 1 +} + +# ---- gbrain sources operations ---- +# Returns 0 if source with id exists at expected path. 1 if exists but path differs. 2 if absent. +# Hard-fails (exits non-zero via die) if jq is missing — without jq we cannot +# distinguish "absent" from "missing-tool" and would falsely re-add an existing +# source. jq is documented as a dependency of gstack-gbrain-detect (transitive) +# but adversarial review flagged the silent-fall-through path; this probe makes +# the failure mode loud. +check_source_state() { + local id="$1" + if ! command -v jq >/dev/null 2>&1; then + die "jq required for source state detection. Install jq (brew install jq) and re-run." 1 + fi + local existing_path + existing_path=$(gbrain sources list --json 2>/dev/null \ + | jq -r --arg id "$id" '.sources[] | select(.id==$id) | .local_path' 2>/dev/null \ + | tr -d '[:space:]') || existing_path="" + if [ -z "$existing_path" ]; then + return 2 + fi + if [ "$existing_path" = "$WORKTREE" ]; then + return 0 + fi + return 1 +} + +# ---- modes ---- +do_probe() { + local id worktree_status="absent" gbrain_status="missing" source_status="absent" + id=$(derive_source_id 2>/dev/null) || id="(unknown)" + # Use explicit if-block so [ -d ] || [ -f ] doesn't get short-circuited by && + # precedence (the `||` and `&&` chain has trap behavior in bash test syntax). + if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then + worktree_status="present" + fi + if gbrain_version_ok; then + gbrain_status="ok ($(gbrain --version 2>/dev/null | awk '{print $2}'))" + # Capture check_source_state's return code explicitly. Relying on $? after + # an `if`-elif chain is fragile under set -e and undefined under some shells. + set +e + check_source_state "$id" + local css_rc=$? + set -e + case "$css_rc" in + 0) source_status="registered ($WORKTREE)" ;; + 1) source_status="registered (different path)" ;; + esac + fi + echo "source_id=$id" + echo "worktree=$WORKTREE" + echo "worktree_status=$worktree_status" + echo "gbrain=$gbrain_status" + echo "source_status=$source_status" +} + +do_wireup() { + local id + id=$(derive_source_id) || die "cannot derive source id (no .git, no remote-file, no --source-id)" 2 + + if ! gbrain_version_ok; then + if [ "$STRICT" = "1" ]; then + die "gbrain not installed or < 0.18.0; install/upgrade gbrain and re-run" 2 + fi + warn "gbrain not installed or < 0.18.0; skipping wireup (benign skip)" + exit 0 + fi + + # Capture ensure_worktree's return code explicitly. `$?` after `||` reflects + # the LAST command in the function under set -e, which is unreliable when the + # function has multiple internal exit paths. + set +e + ensure_worktree + ew_rc=$? + set -e + case "$ew_rc" in + 0) : ;; # success + 2) + [ "$STRICT" = "1" ] && die "no $GSTACK_HOME/.git; run /setup-gbrain Step 7 (gstack-brain-init) first" 2 + warn "no $GSTACK_HOME/.git; skipping (benign skip)" + exit 0 + ;; + *) die "git worktree creation failed at $WORKTREE" 1 ;; + esac + + # Source registration: probe state, then act. + set +e + check_source_state "$id" + local sstate=$? + set -e + case "$sstate" in + 0) : ;; # already correctly registered + 1) + # Multi-Mac case: if the existing path also looks like another machine's + # brain-worktree (same basename, different parent), don't ping-pong the + # registration. Just sync from our local worktree — gbrain stores pages + # by content, not by local_path. The metadata is informational only. + local existing_path + existing_path=$(gbrain sources list --json 2>/dev/null \ + | jq -r --arg id "$id" '.sources[] | select(.id==$id) | .local_path' 2>/dev/null \ + | tr -d '[:space:]') || existing_path="" + if [ "$(basename "$existing_path")" = "$(basename "$WORKTREE")" ] \ + && [ "$existing_path" != "$WORKTREE" ]; then + warn "source $id is registered at $existing_path (likely another machine's local copy of the same brain repo). Skipping re-registration; will sync from local worktree." + else + warn "source $id registered with different path; recreating (gbrain has no 'sources update')" + gbrain sources remove "$id" --yes 2>&1 | prefix || die "gbrain sources remove failed" 1 + gbrain sources add "$id" --path "$WORKTREE" --federated 2>&1 | prefix \ + || die "gbrain sources add failed" 1 + fi + ;; + 2) + gbrain sources add "$id" --path "$WORKTREE" --federated 2>&1 | prefix \ + || die "gbrain sources add failed" 1 + ;; + esac + + # ZeroEntropy sunset advisory (#2365): the provider shuts down Sept 4, 2026, + # after which brains on gbrain's zeroentropyai recipe stop embedding new + # pages silently. Detection is a fail-open grep of gbrain's config — any + # missing/unreadable/other-provider config stays silent (grep -qs), never + # blocking a working setup. + if grep -qsi 'zeroentropyai' "$GBRAIN_CONFIG" 2>/dev/null; then + warn "gbrain config appears to use the ZeroEntropy embedding recipe. ZeroEntropy sunsets on September 4, 2026 — after that, new pages stop embedding silently. Migration options: https://github.com/garrytan/gstack/issues/2365" + fi + + if [ "${GSTACK_BRAIN_NO_SYNC:-0}" = "1" ]; then + echo "source_id=$id" + echo "worktree=$WORKTREE" + echo "pages_synced=skipped" + exit 0 + fi + + # #2662: `sync --repo ` resolves against the brain's DEFAULT source and + # can silently repoint that source's local_path anchor at our worktree while + # the source registered above gets nothing. Target the registered source by + # id. `--source` support is probed first (the documented floor is gbrain >= + # 0.18.0 and nothing proves the flag exists there): an older gbrain keeps the + # wrong-but-working --repo call with an upgrade warning, never a hard failure. + local sync_out sync_redacted + local -a sync_cmd + if gbrain sync --help 2>/dev/null | grep -q -- '--source'; then + sync_cmd=(gbrain sync --source "$id") + else + warn "this gbrain's sync lacks --source; falling back to 'sync --repo' (upgrade gbrain so the sync targets source $id directly — #2662)" + sync_cmd=(gbrain sync --repo "$WORKTREE") + fi + sync_out=$("${sync_cmd[@]}" 2>&1) || { + # Redact any postgres:// URLs from the error message in case gbrain logged + # a connection error containing the full DSN with password. The user sees + # "***REDACTED***" instead of credentials in their stderr or any log. + sync_redacted=$(echo "$sync_out" | tail -10 | sed -E 's#postgres(ql)?://[^[:space:]]+#postgres://***REDACTED***#g') + die "gbrain sync failed (last 10 lines, secrets redacted): $sync_redacted" 1 + } + echo "$sync_out" | tail -3 | prefix + + echo "source_id=$id" + echo "worktree=$WORKTREE" + echo "pages_synced=$(echo "$sync_out" | grep -oE '[0-9]+ pages? imported' | head -1 || echo 'incremental')" +} + +do_advance_only() { + # Daily unattended advance (#2516): the brain worktree gbrain indexes only + # moved when setup-gbrain / sync-gbrain / brain-restore ran, so brains + # silently served stale code. This mode is git-only (no gbrain prereqs) and + # SAFE for a cron cadence: it refuses dirty worktrees and NEVER runs + # ensure_worktree's force-remove recovery — an unattended path must not be + # able to delete local worktree changes. All git ops are pinned to + # $GSTACK_HOME / $WORKTREE, never cwd-derived. + [ -d "$GSTACK_HOME/.git" ] || { warn "advance-only: no artifacts repo at $GSTACK_HOME; nothing to advance"; exit 0; } + if [ ! -d "$WORKTREE/.git" ] && [ ! -f "$WORKTREE/.git" ]; then + warn "advance-only: no managed worktree at $WORKTREE (run the setup-gbrain wireup first)" + exit 0 + fi + # Managed-marker check: refuse anything that is not a worktree OF the + # artifacts repo — a misconfigured GSTACK_BRAIN_WORKTREE pointing at a user + # repo must never be advanced/detached. + local gitdir home_git + gitdir=$(git -C "$WORKTREE" rev-parse --absolute-git-dir 2>/dev/null || echo "") + # Physical path for the comparison: rev-parse returns resolved paths, while + # $GSTACK_HOME may reach the same place through a symlink (macOS /var/folders). + home_git=$(cd "$GSTACK_HOME/.git" 2>/dev/null && pwd -P || echo "$GSTACK_HOME/.git") + case "$gitdir" in + "$home_git/worktrees/"*) : ;; + *) warn "advance-only: $WORKTREE is not a worktree of $GSTACK_HOME (gitdir: ${gitdir:-unreadable}); refusing"; exit 0 ;; + esac + if [ -n "$(git -C "$WORKTREE" status --porcelain 2>/dev/null)" ]; then + warn "advance-only: worktree at $WORKTREE has local changes; refusing to advance them away" + exit 0 + fi + local sha cur + sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || { warn "advance-only: cannot read parent HEAD"; exit 0; } + cur=$(git -C "$WORKTREE" rev-parse HEAD 2>/dev/null || echo "") + if [ "$cur" = "$sha" ]; then + echo "advance-only: up-to-date at $sha" + return 0 + fi + if ( cd "$WORKTREE" && git checkout --detach "$sha" 2>&1 | prefix; exit "${PIPESTATUS[0]}" ); then + echo "advance-only: advanced $WORKTREE to $sha" + else + warn "advance-only: could not advance $WORKTREE to $sha; NOT force-resetting on the unattended path. Run gstack-gbrain-source-wireup to repair." + exit 1 + fi +} + +do_uninstall() { + local id + id=$(derive_source_id) || die "cannot derive source id; pass --source-id explicitly" 3 + + if command -v gbrain >/dev/null 2>&1; then + gbrain sources remove "$id" --yes 2>&1 | prefix || warn "gbrain sources remove failed (continuing)" + fi + + if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then + git -C "$GSTACK_HOME" worktree remove --force "$WORKTREE" 2>/dev/null \ + || safe_rm_worktree "$WORKTREE" + fi + + # Cron-stub: future launchd plist (not created today; safety net for D9 future). + rm -f "$PLIST_PATH" 2>/dev/null || true + + echo "uninstalled source=$id worktree=$WORKTREE" +} + +case "$MODE" in + probe) do_probe ;; + wireup) do_wireup ;; + uninstall) do_uninstall ;; + advance-only) do_advance_only ;; +esac diff --git a/.agents/skills/gstack/bin/gstack-gbrain-supabase-provision b/.agents/skills/gstack/bin/gstack-gbrain-supabase-provision new file mode 100755 index 0000000..c3d3029 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-supabase-provision @@ -0,0 +1,29 @@ +#!/usr/bin/env -S bun run +/** + * gstack-gbrain-supabase-provision — Supabase Management API wrapper for + * /setup-gbrain path 2a (auto-provision). Thin entry: all logic lives in + * lib/gbrain-supabase-provision.ts so tests can drive it in-process with + * injected fetch/env/sleep instead of spawning a process per test. + * + * Rewritten from bash to TypeScript; filename and exec semantics unchanged — + * callers shell out to this path and the bun shebang resolves at runtime + * (same pattern as bin/gstack-gbrain-detect). CLI surface, stdout/stderr + * shapes, env handling (SUPABASE_ACCESS_TOKEN / DB_PASS / SUPABASE_API_BASE), + * and exit codes are unchanged; run --help for the full contract. + * + * Egress receipts stay fail-closed at the API-call layer (sink + * "supabase-provision", receipt-before-send) — see the module header. + */ + +import { runProvision } from '../lib/gbrain-supabase-provision'; + +// exitCode, not process.exit(): exit() drops pending stdout writes, which +// truncates piped JSON / large listings; setting exitCode lets writes drain +// and the process exit naturally. +runProvision(process.argv.slice(2)).then( + (code) => { process.exitCode = code; }, + (error) => { + process.stderr.write(`gstack-gbrain-supabase-provision: ${(error as Error)?.stack ?? error}\n`); + process.exitCode = 1; + }, +); diff --git a/.agents/skills/gstack/bin/gstack-gbrain-supabase-verify b/.agents/skills/gstack/bin/gstack-gbrain-supabase-verify new file mode 100755 index 0000000..5a3b04c --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-supabase-verify @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# gstack-gbrain-supabase-verify — structural check on a Supabase Session +# Pooler URL before handing it to `gbrain init`. +# +# Usage: +# gstack-gbrain-supabase-verify +# echo "" | gstack-gbrain-supabase-verify - +# +# Accepts ONLY Session Pooler URLs (port 6543, host *.pooler.supabase.com). +# Rejects direct-connection URLs (db.*.supabase.co:5432) since those are +# IPv6-only and fail in many environments — gbrain's init wizard warns +# about this at init.ts:150-158. +# +# Canonical shape (per gbrain init.ts:266): +# postgresql://postgres.:@aws-0-.pooler.supabase.com:6543/postgres +# +# Exit codes: +# 0 — URL passes structural check +# 2 — invalid format (bad scheme, port, host, userinfo, or empty password) +# 3 — direct-connection URL rejected (common mistake, special-cased for UX) +# +# The verifier never makes a network call; purely a regex match. Whether +# the URL actually works (database up, password correct, host reachable) +# is gbrain's problem at init time. +# +# Reads URL from: +# 1. argv[1] if provided and not "-" +# 2. stdin if argv[1] is "-" or missing +# +# Never echoes the URL to stderr (it contains a password). Error messages +# refer to "the URL" generically. +set -euo pipefail + +die() { echo "gstack-gbrain-supabase-verify: $*" >&2; exit 2; } +reject_direct() { + cat >&2 <:@aws-0-.pooler.supabase.com:6543/postgres +EOF + exit 3 +} + +URL="" +case "${1:-}" in + -) URL=$(cat) ;; + "") URL=$(cat) ;; + *) URL="$1" ;; +esac + +URL=$(printf '%s' "$URL" | tr -d '[:space:]') +[ -z "$URL" ] && die "empty URL" + +# Scheme: must be postgresql:// or postgres://. Explicitly reject other +# schemes rather than guess. +case "$URL" in + postgresql://*|postgres://*) ;; + *) die "bad scheme (must start with postgresql:// or postgres://)" ;; +esac + +# Strip scheme to expose userinfo + host + port + path. +rest="${URL#*://}" + +# Userinfo portion: everything before the first @. Must contain a : (user:pass). +case "$rest" in + *@*) ;; + *) die "missing userinfo (expected postgres.:@host)" ;; +esac +userinfo="${rest%%@*}" +after_at="${rest#*@}" + +# Userinfo must be user:password with neither part empty. +case "$userinfo" in + *:*) ;; + *) die "userinfo missing password separator (expected user:password@)" ;; +esac +user_part="${userinfo%%:*}" +pass_part="${userinfo#*:}" +[ -z "$user_part" ] && die "empty user portion in userinfo" +[ -z "$pass_part" ] && die "empty password in userinfo" + +# Host + port + path. +# Direct-connection detection FIRST (specific error beats generic). +case "$after_at" in + db.*.supabase.co:5432*|db.*.supabase.co/*|db.*.supabase.co) reject_direct ;; +esac + +# Extract host:port (before first / if present). +hostport="${after_at%%/*}" +case "$hostport" in + *:*) ;; + *) die "missing port (Session Pooler requires :6543)" ;; +esac +host="${hostport%:*}" +port="${hostport##*:}" + +# Host must be *.pooler.supabase.com (case-insensitive). +host_lower=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]') +case "$host_lower" in + *.pooler.supabase.com) ;; + *) die "host '$host' is not a Supabase Session Pooler (expected *.pooler.supabase.com)" ;; +esac + +# Port must be 6543 (Session Pooler default). +if [ "$port" != "6543" ]; then + die "port must be 6543 for Session Pooler (got $port)" +fi + +# User portion should look like postgres. (20-char lowercase ref, +# per the Supabase Management API contract). Not strictly required by +# gbrain, but rejecting a plain "postgres" user catches a common paste +# error where someone grabs the Direct URL userinfo by mistake. +case "$user_part" in + postgres.*) ;; + *) die "user portion '$user_part' should be 'postgres.' (20-char ref)" ;; +esac + +echo "ok" diff --git a/.agents/skills/gstack/bin/gstack-gbrain-sync.ts b/.agents/skills/gstack/bin/gstack-gbrain-sync.ts new file mode 100644 index 0000000..4e3034b --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-gbrain-sync.ts @@ -0,0 +1,1774 @@ +#!/usr/bin/env bun +/** + * gstack-gbrain-sync — V1 unified sync verb. + * + * Orchestrates three storage tiers per plan §"Storage tiering": + * + * 1. Code (current repo) → `gbrain sources add` (idempotent via + * lib/gbrain-sources.ts) + `gbrain sync + * --strategy code` (incremental) or + * `gbrain reindex-code --yes` (--full). + * NEVER `gbrain import` (markdown only). + * 2. Transcripts + curated memory → gstack-memory-ingest (typed put_page) + * 3. Curated artifacts to git → gstack-brain-sync (existing pipeline) + * + * Modes: + * --incremental (default) — mtime fast-path; runs all 3 stages with cache hits + * --full — first-run; full walk + reindex; honest budget per ED2 + * --dry-run — preview what would sync; no writes anywhere (incl. state file) + * + * Concurrency safety per /plan-eng-review D1: + * - Lock file at ~/.gstack/.sync-gbrain.lock (PID + start ts). + * - Stale-lock takeover after 5 min (process death). + * - State file written via tmp+rename for atomicity. + * - Lock released in finally; SIGINT/SIGTERM trapped for cleanup. + * + * --watch (V1.5 P0 TODO): file-watcher daemon. NOTE: gbrain v0.25.1 already + * ships `gbrain sync --watch [--interval N]` and `gbrain sync --install-cron`; + * when revisited, /sync-gbrain --watch wires through to the gbrain CLI rather + * than building a gstack-side daemon. + */ + +import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync } from "fs"; +import { join, dirname } from "path"; +import { execSync, spawnSync } from "child_process"; +import { homedir, hostname } from "os"; +import { createHash } from "crypto"; + +import "../lib/conductor-env-shim"; +import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; +import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources"; +import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; +import { writeReceipt } from "../lib/egress-receipt"; +import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; +import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec"; +import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; +import { checkOwnedStagingDir } from "../lib/staging-guard"; + +// ── Types ────────────────────────────────────────────────────────────────── + +type Mode = "incremental" | "full" | "dry-run"; + +export interface CliArgs { + mode: Mode; + quiet: boolean; + noCode: boolean; + noMemory: boolean; + noBrainSync: boolean; + codeOnly: boolean; + /** Force the source-scoped dream cycle (builds this source's call graph). Always runs. */ + dream: boolean; + /** Opt out of the dream cycle that `--full` would otherwise auto-run. */ + noDream: boolean; + /** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */ + allowReclone: boolean; +} + +interface CodeStageDetail { + source_id?: string; + source_path?: string; + page_count?: number | null; + last_imported?: string; + status?: + | "ok" + | "skipped" + | "failed" + | "refused-autopilot" + | "refused-reclone" + | "refused-egress-receipt"; +} + +interface StageResult { + name: string; + ran: boolean; + ok: boolean; + duration_ms: number; + summary: string; + /** + * Stage ran and did not error, but the outcome is a degraded no-op the user + * should know about (e.g. dream completed but the schema pack can't extract + * code symbols, so the call graph stays empty). Rendered as WARN, counts as + * ok for the exit code — it's not a failure, just not the happy path. + */ + warn?: boolean; + /** Stage-specific structured detail. Code stage carries source_id + page_count. */ + detail?: CodeStageDetail; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const HOME = homedir(); +const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json"); +const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock"); +const STALE_LOCK_MS = 5 * 60 * 1000; + +// Dream (call-graph build) is brain-global and runs LOCK-FREE after the sync +// lock releases, so it can't use the sync lock to dedupe across worktrees. A +// dedicated short-TTL marker prevents two worktrees from launching duplicate +// ~35-min global jobs. TTL matches the dream timeout default so a crashed run +// can't wedge the marker longer than one cycle. +const DEFAULT_DREAM_TIMEOUT_MS = 45 * 60 * 1000; // 45min — dream is the slow stage +const DREAM_MARKER_STALE_MS = DEFAULT_DREAM_TIMEOUT_MS; + +/** + * Marker path computed fresh per call (not a module const) so tests can mutate + * GSTACK_HOME at runtime — same pattern as cacheFilePath() in + * lib/gbrain-local-status.ts. Avoids the ESM static-import hoist trap where a + * module-load-time const captures the real ~/.gstack before a test can redirect. + */ +export function dreamMarkerPath(): string { + return join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), ".dream-in-progress"); +} + +// Default 35-minute timeout for code-walk + memory-ingest stages. Override via +// GSTACK_SYNC_CODE_TIMEOUT_MS / GSTACK_SYNC_MEMORY_TIMEOUT_MS. Bounds-checked +// in resolveStageTimeoutMs below so wildly-low values don't make resume +// useless and wildly-high values don't mask config typos. See #1611. +const DEFAULT_STAGE_TIMEOUT_MS = 35 * 60 * 1000; // 2_100_000ms = 35min +const MIN_STAGE_TIMEOUT_MS = 60_000; // 1 minute floor +const MAX_STAGE_TIMEOUT_MS = 86_400_000; // 24 hour ceiling + +/** + * Parse a stage-timeout env value with bounds validation. Returns the bounded + * value or the default with a stderr warning if the env was malformed or + * out-of-range. Exported for the regression test. + */ +export function resolveStageTimeoutMs( + envValue: string | undefined, + envName: string, + defaultMs: number = DEFAULT_STAGE_TIMEOUT_MS, +): number { + if (envValue === undefined || envValue === "") return defaultMs; + const n = Number.parseInt(envValue, 10); + if (!Number.isFinite(n) || Number.isNaN(n) || n <= 0) { + console.warn( + `[sync] ${envName}="${envValue}" is not a positive integer; falling back to ${defaultMs}ms`, + ); + return defaultMs; + } + if (n < MIN_STAGE_TIMEOUT_MS) { + console.warn( + `[sync] ${envName}=${n} is below the ${MIN_STAGE_TIMEOUT_MS}ms (1min) floor; falling back to ${defaultMs}ms`, + ); + return defaultMs; + } + if (n > MAX_STAGE_TIMEOUT_MS) { + console.warn( + `[sync] ${envName}=${n} is above the ${MAX_STAGE_TIMEOUT_MS}ms (24h) ceiling; falling back to ${defaultMs}ms`, + ); + return defaultMs; + } + return n; +} + +/** + * gbrain writes ~/.gbrain/import-checkpoint.json on every import run. If a + * previous /sync-gbrain hit the timeout (SIGTERM = exit 143), the checkpoint + * + its staging dir survive on disk. Detect both and let gbrain resume from + * processedIndex+1 on the next run. If the staging dir is missing/empty/ + * unreadable, fall through to a fresh restage with a one-line warning so the + * user sees we noticed. See #1611 + plan D1/C1. + */ +interface GbrainCheckpoint { + dir?: string; + totalFiles?: number; + processedIndex?: number; + completedFiles?: number; + timestamp?: string; +} + +export function readGbrainCheckpoint(): GbrainCheckpoint | null { + // Read HOME from env so tests can redirect via process.env.HOME = ... + // (Node/Bun's os.homedir() caches at process start and ignores later + // mutations.) + const home = process.env.HOME || homedir(); + const cpPath = join(home, ".gbrain", "import-checkpoint.json"); + if (!existsSync(cpPath)) return null; + try { + const raw = readFileSync(cpPath, "utf-8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + return parsed as GbrainCheckpoint; + } catch { + // Corrupt JSON — treat as no checkpoint and fall through to fresh restage. + return null; + } +} + +export type ResumeVerdict = + | { kind: "no-checkpoint" } + | { kind: "resume"; stagingDir: string; processedIndex: number; totalFiles: number } + | { kind: "stale-staging-missing"; stagingDir: string; reason?: string }; + +/** + * Decide whether the next memory-ingest run should resume from gbrain's + * checkpoint or restage from scratch. + * - no checkpoint → run a fresh ingest pass + * - checkpoint + staging ok → resume (gbrain picks up at processedIndex+1) + * - checkpoint + staging gone → warn, fall through to fresh restage + */ +export function decideResume(gstackHome: string = GSTACK_HOME): ResumeVerdict { + const cp = readGbrainCheckpoint(); + if (!cp || !cp.dir) return { kind: "no-checkpoint" }; + const stagingDir = cp.dir; + // #1802: only resume into a path we can PROVE is a gstack-minted staging dir. + // A poisoned checkpoint (dir = repo root, written when an autopilot import was + // SIGTERM'd while CWD was the repo) would otherwise be adopted as the staging + // dir and later recursively deleted by cleanupStagingDir(). Fail-closed: any + // unprovable path restages from scratch (cost: one re-stage; never data loss). + // Pure decision: return the verdict (with reason) and let the caller log, + // so we don't double-log the same event from here and the call site. + const verdict = checkOwnedStagingDir(stagingDir, gstackHome); + if (!verdict.ok) { + return { kind: "stale-staging-missing", stagingDir, reason: verdict.reason }; + } + return { + kind: "resume", + stagingDir, + processedIndex: cp.processedIndex ?? 0, + totalFiles: cp.totalFiles ?? 0, + }; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function printUsage(): void { + console.error(`Usage: gstack-gbrain-sync [--incremental|--full|--dry-run] [options] + +Modes: + --incremental Default. mtime fast-path; ~50ms steady-state. + --full First-run; full walk + reindex. Honest ~25-35 min for big Macs (ED2). + --dry-run Preview what would sync; no writes anywhere. + +Options: + --quiet Suppress per-stage output. + --no-code Skip the cwd code-import stage. + --no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts). + --no-brain-sync Skip the gstack-brain-sync git pipeline stage. + --code-only Only run the code-import stage (alias for --no-memory --no-brain-sync). + --dream Force the source-scoped dream cycle that builds this + source's call graph (gbrain code-callers/code-callees). + Runs lock-free AFTER the sync stages. ~minutes. Default + timeout 45min, override GSTACK_SYNC_DREAM_TIMEOUT_MS. + --no-dream Opt out of the dream cycle that --full would auto-run. + --allow-reclone Permit the code walk for URL-managed sources (remote_url set) + even though gbrain may auto-reclone the working tree (#1734). + --help This text. + +Stages run in order: code → memory ingest → curated git push, then (lock-free) +the optional dream call-graph build. --full auto-runs dream ONLY when the call +graph was never built; --dream always forces it. Each stage failure is +non-fatal; subsequent stages still run. +`); +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + let mode: Mode = "incremental"; + let quiet = false; + let noCode = false; + let noMemory = false; + let noBrainSync = false; + let codeOnly = false; + let dream = false; + let noDream = false; + let allowReclone = false; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + switch (a) { + case "--incremental": mode = "incremental"; break; + case "--full": mode = "full"; break; + case "--dry-run": mode = "dry-run"; break; + case "--quiet": quiet = true; break; + case "--no-code": noCode = true; break; + case "--no-memory": noMemory = true; break; + case "--no-brain-sync": noBrainSync = true; break; + case "--allow-reclone": allowReclone = true; break; + case "--code-only": + codeOnly = true; + noMemory = true; + noBrainSync = true; + break; + // --dream forces the cycle; --full only chains it at the call site (so + // --no-dream can override) — do NOT set dream from --full here. + case "--dream": dream = true; break; + case "--no-dream": noDream = true; break; + case "--help": + case "-h": + printUsage(); + process.exit(0); + default: + console.error(`Unknown argument: ${a}`); + printUsage(); + process.exit(1); + } + } + + return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, dream, noDream, allowReclone }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function repoRoot(): string | null { + try { + const out = execSync("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 2000 }); + return out.trim(); + } catch { + return null; + } +} + +function originUrl(): string | null { + try { + const out = execSync("git remote get-url origin", { encoding: "utf-8", timeout: 2000 }); + return out.trim(); + } catch { + return null; + } +} + +/** + * Derive a host- and worktree-aware source id for the cwd code corpus. + * + * Pattern: `gstack-code--` where slug comes from origin + * (org/repo) and hostpathhash8 is the first 8 hex chars of + * sha1(`${hostname}::${absolute repo path}`). Folding hostname into the hash + * keeps Conductor worktrees of the same repo as distinct sources on one host + * AND keeps two machines that share an absolute layout (e.g. chezmoi-managed + * home dirs against a federated brain) from colliding on each other. + * + * Falls back to the repo basename when there is no origin (local repo). + * + * `GSTACK_HOSTNAME` env override is honored for deterministic tests; in + * production paths it is unset and `os.hostname()` is used. + * + * gbrain enforces source ids to be 1-32 lowercase alnum chars with + * optional interior hyphens. `constrainSourceId` handles the 32-char cap + * with a hashed-tail fallback when the combined slug exceeds budget. + */ +function deriveCodeSourceId(repoPath: string): string { + const host = process.env.GSTACK_HOSTNAME || hostname(); + const hostPathHash = createHash("sha1").update(`${host}::${repoPath}`).digest("hex").slice(0, 8); + const remote = canonicalizeRemote(originUrl()); + if (remote) { + const segs = remote.split("/").filter(Boolean); + const slugSource = segs.slice(-2).join("-"); + const fullId = constrainSourceId("gstack-code", `${slugSource}-${hostPathHash}`); + // If the org+repo+hostpathhash fits cleanly (suffix preserved), use it. + if (fullId.endsWith(`-${hostPathHash}`)) return fullId; + // Otherwise drop the org prefix and retry with just repo+hostpathhash so + // the repo name stays readable. If that still doesn't fit, + // constrainSourceId falls back to a deterministic hash-only form. + const repoOnly = segs[segs.length - 1] || "repo"; + return constrainSourceId("gstack-code", `${repoOnly}-${hostPathHash}`); + } + const base = repoPath.split("/").pop() || "repo"; + return constrainSourceId("gstack-code", `${base}-${hostPathHash}`); +} + +/** + * Reuse an explicit repo pin when it names a registered source for this exact + * checkout. The path check prevents a stale or copied dotfile from redirecting + * a code sync into another repo's source. + */ +function readPinnedSourceId(repoPath: string): string | null { + const pinPath = join(repoPath, ".gbrain-source"); + if (!existsSync(pinPath)) return null; + + try { + const sourceId = readFileSync(pinPath, "utf-8").trim(); + return /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(sourceId) ? sourceId : null; + } catch { + // A pin is advisory. A permission race or a directory at this path must + // not turn a sync preview into an unexpected crash. + return null; + } +} + +export function existingPinnedSourceId(repoPath: string, env?: NodeJS.ProcessEnv): string | null { + const sourceId = readPinnedSourceId(repoPath); + if (!sourceId) return null; + + const registeredPath = sourceLocalPath(sourceId, env); + if (!registeredPath) return null; + try { + return realpathSync(registeredPath) === realpathSync(repoPath) ? sourceId : null; + } catch { + return null; + } +} + +function resolveCodeSourceId(repoPath: string, env?: NodeJS.ProcessEnv): string { + return existingPinnedSourceId(repoPath, env) ?? deriveCodeSourceId(repoPath); +} + +/** + * Pre-pathhash source id, kept for orphan detection only. + * + * Earlier /sync-gbrain versions registered `gstack-code-` (no pathhash + * suffix). On a multi-worktree repo, those collapsed onto a single source id + * with last-sync-wins semantics. The new path-keyed id leaves the legacy + * source orphaned in the brain — federated cross-source search would return + * stale duplicate hits. We remove the legacy id once, on the first new-format + * sync from any worktree of this repo, so users don't accumulate orphans. + */ +function deriveLegacyCodeSourceId(repoPath: string): string { + const remote = canonicalizeRemote(originUrl()); + if (remote) { + const segs = remote.split("/").filter(Boolean); + const slugSource = segs.slice(-2).join("-"); + return constrainSourceId("gstack-code", slugSource); + } + const base = repoPath.split("/").pop() || "repo"; + return constrainSourceId("gstack-code", base); +} + +/** + * Pre-#1468 path-only-hash source id, kept for hostname-fold migration only. + * + * Before the hostname fold, `deriveCodeSourceId` hashed only the absolute + * repo path: `gstack-code--`. After #1468 the + * hash key is `${hostname}::${path}`, so every existing user's brain has a + * legacy id that no longer matches what `deriveCodeSourceId` produces. We + * detect this form once, attempt rename-in-place if the gbrain CLI supports + * `sources rename`, and otherwise clean up after the new source successfully + * syncs. Distinct from `deriveLegacyCodeSourceId` (pre-pathhash v1.x form); + * both probes run. + */ +export function derivePathOnlyHashLegacyId(repoPath: string): string { + const pathHash = createHash("sha1").update(repoPath).digest("hex").slice(0, 8); + const remote = canonicalizeRemote(originUrl()); + if (remote) { + const segs = remote.split("/").filter(Boolean); + const slugSource = segs.slice(-2).join("-"); + return constrainSourceId("gstack-code", `${slugSource}-${pathHash}`); + } + const base = repoPath.split("/").pop() || "repo"; + return constrainSourceId("gstack-code", `${base}-${pathHash}`); +} + +/** + * Feature-check whether the installed gbrain CLI ships `sources rename `. + * + * Per the v1.40.0.0 design review: probing `gbrain sources rename --help` and + * matching for the exact argument shape catches the case where gbrain's + * `sources` parent help mentions a `rename` subcommand but the CLI doesn't + * accept the ` ` form (or vice versa). Cached for the lifetime + * of the process. As of gbrain 0.35.0.0 this command does not exist, so the + * function returns false and the migration path falls back to register-new + * + sync-OK + remove-old. + */ +let _gbrainSupportsRenameCache: boolean | null = null; +export function _resetGbrainSupportsRenameCache(): void { + _gbrainSupportsRenameCache = null; +} +function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean { + if (_gbrainSupportsRenameCache !== null) return _gbrainSupportsRenameCache; + try { + const r = spawnGbrain(["sources", "rename", "--help"], { + timeout: 5_000, + baseEnv: env, + }); + const out = `${r.stdout || ""}\n${r.stderr || ""}`; + // Match the exact argument shape: `rename ` (with literal + // angle brackets in usage strings) or `rename OLD NEW`. + const exact = /sources\s+rename\s+\s+/i.test(out) + || /sources\s+rename\s+OLD\s+NEW/.test(out) + || /sources\s+rename\s+\s+/i.test(out); + _gbrainSupportsRenameCache = exact && r.status === 0; + } catch { + _gbrainSupportsRenameCache = false; + } + return _gbrainSupportsRenameCache; +} + +/** + * Look up a source's `local_path` from `gbrain sources list --json`. + * Returns null when the source is absent or the listing fails. + * + * `env` is the environment passed to the spawned `gbrain` process; defaults + * to `process.env`. Tests inject a PATH that points at a gbrain shim so the + * helper can be exercised without a real gbrain CLI. + * + * Shape note: `gbrain sources list --json` returns `{sources: [...]}` (v0.20+); + * older versions returned a flat array. Accept both for forward/backward compat + * (mirrors `probeSource`/`sourcePageCount` in lib/gbrain-sources.ts). + */ +export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): string | null { + const raw = execGbrainJson( + ["sources", "list", "--json"], + { baseEnv: env }, + ); + if (!raw) return null; + const found = parseSourcesList(raw).find((s) => s.id === sourceId); + return found?.local_path ?? null; +} + +/** Result of `planHostnameFoldMigration` — informs `runCodeImport` of next steps. */ +export type HostnameFoldMigration = + | { kind: "none"; reason: "ids-match" | "no-legacy-source" } + | { kind: "skipped-path-drift"; oldId: string; oldPath: string; currentPath: string } + | { kind: "renamed"; oldId: string; newId: string } + | { kind: "pending-cleanup"; oldId: string }; + +/** + * Decide how to migrate from the pre-#1468 path-only-hash source id to the + * new hostname-fold id. + * + * Order: + * 1. If old == new → no-op. + * 2. Look up old source's local_path. Absent → no legacy source to migrate. + * 3. local_path != currentRoot → user moved the repo or two machines share a + * hash slot. Skip migration; let the user clean up manually. We will NOT + * rename or remove anything; the new source is registered alongside. + * 4. Otherwise: feature-check `gbrain sources rename`. If supported and the + * rename call exits 0 → renamed, pages preserved. + * 5. Else: pending-cleanup. Caller registers + syncs new source first; only + * after sync succeeds with a non-zero page count does it remove the old. + * This avoids a data-loss window where the old source is gone before the + * new one is verifiably populated. + */ +export function planHostnameFoldMigration( + currentRoot: string, + newSourceId: string, + legacyPathHashId: string, + env?: NodeJS.ProcessEnv, +): HostnameFoldMigration { + if (legacyPathHashId === newSourceId) { + return { kind: "none", reason: "ids-match" }; + } + const oldPath = sourceLocalPath(legacyPathHashId, env); + if (oldPath === null) { + return { kind: "none", reason: "no-legacy-source" }; + } + if (oldPath !== currentRoot) { + return { + kind: "skipped-path-drift", + oldId: legacyPathHashId, + oldPath, + currentPath: currentRoot, + }; + } + if (gbrainSupportsSourcesRename(env)) { + const r = spawnGbrain(["sources", "rename", legacyPathHashId, newSourceId], { baseEnv: env }); + if (r.status === 0) { + return { kind: "renamed", oldId: legacyPathHashId, newId: newSourceId }; + } + // Rename failed at runtime — fall through to cleanup path. + } + return { kind: "pending-cleanup", oldId: legacyPathHashId }; +} + +export interface GuardedRemoveResult { + removed: boolean; + /** True when a guard refused the remove (autopilot active or unsafe source). */ + skipped: boolean; + reason: string; +} + +/** + * #1734: run `gbrain sources remove --confirm-destructive` only behind the + * data-loss guards. Checked immediately before the destructive op (E8: as late + * as possible) so the autopilot window is as small as we can make it without a + * gbrain-side lease. Refuses when autopilot is active or when the source is + * user-managed and gbrain can't keep its storage. Pure side-effect helper; the + * caller decides whether a skip is fatal (it never is today — removes are + * best-effort cleanup). + */ +export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult { + const ap = detectAutopilot(env); + if (ap.active) { + return { + removed: false, + skipped: true, + reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` + + `Stop autopilot, then re-run /sync-gbrain.`, + }; + } + const decision = decideSourceRemove(sourceId, env); + if (!decision.allow) { + return { removed: false, skipped: true, reason: decision.reason }; + } + const r = spawnGbrain( + ["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs], + { baseEnv: env }, + ); + return { removed: r.status === 0, skipped: false, reason: decision.reason }; +} + +/** + * Remove an orphaned source. Called only after new-source sync verifies pages + * exist, so the old source is provably redundant before deletion. Routed through + * safeSourcesRemove for the #1734 guards. + */ +export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean { + return safeSourcesRemove(oldId, env).removed; +} + +/** + * Build a gbrain-valid source id (1-32 lowercase alnum + interior hyphens). Sanitizes + * `raw`, prefixes with `prefix`, and falls back to a hashed-tail form when total length + * would exceed 32 chars. + * + * Truncation cuts on hyphen boundaries (whole-word units) from the right, never + * mid-word. Inputs like "drummerms-av-sow-wiz-skill-270c0001" produce + * "${prefix}-270c0001-", not "${prefix}-kill-270c0001-". + */ +function constrainSourceId(prefix: string, raw: string): string { + const MAX = 32; + const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + // Empty slug after sanitize (e.g. raw was all non-alnum like "___") would + // produce "${prefix}-" which fails gbrain's validator on the trailing + // hyphen. Fall back to a deterministic hash of the original input so the + // result is stable across runs of the same repo. + if (!slug) { + const hash = createHash("sha1").update(raw || "_empty").digest("hex").slice(0, 6); + return `${prefix}-${hash}`; + } + const full = `${prefix}-${slug}`; + if (full.length <= MAX) return full; + const hash = createHash("sha1").update(slug).digest("hex").slice(0, 6); + // Total budget: prefix + "-" + tail + "-" + hash + const tailBudget = MAX - prefix.length - 2 - hash.length; + if (tailBudget < 1) return `${prefix}-${hash}`; + // Cut on hyphen boundaries instead of mid-word. Walk tokens from the right, + // accumulating until adding the next token would exceed tailBudget. This + // preserves readable suffixes (pathhash, repo name) and avoids embarrassing + // mid-word artifacts like "skill" → "kill". + const tokens = slug.split("-").filter(Boolean); + const kept: string[] = []; + let len = 0; + for (let i = tokens.length - 1; i >= 0; i--) { + const add = kept.length === 0 ? tokens[i].length : tokens[i].length + 1; + if (len + add > tailBudget) break; + kept.unshift(tokens[i]); + len += add; + } + const tail = kept.join("-"); + return tail ? `${prefix}-${tail}-${hash}` : `${prefix}-${hash}`; +} + +// ── Lock file (D1) ───────────────────────────────────────────────────────── + +interface LockInfo { + pid: number; + started_at: string; +} + +function acquireLock(): boolean { + mkdirSync(GSTACK_HOME, { recursive: true }); + if (existsSync(LOCK_PATH)) { + // Check if stale. + try { + const stat = statSync(LOCK_PATH); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs > STALE_LOCK_MS) { + // Stale; take over. + unlinkSync(LOCK_PATH); + } else { + return false; + } + } catch { + // Cannot stat; bail conservatively. + return false; + } + } + const info: LockInfo = { pid: process.pid, started_at: new Date().toISOString() }; + try { + writeFileSync(LOCK_PATH, JSON.stringify(info), { encoding: "utf-8", flag: "wx" }); + return true; + } catch { + return false; + } +} + +function releaseLock(): void { + try { + if (!existsSync(LOCK_PATH)) return; + const raw = readFileSync(LOCK_PATH, "utf-8"); + const info = JSON.parse(raw) as LockInfo; + if (info.pid === process.pid) { + unlinkSync(LOCK_PATH); + } + } catch { + // Best-effort cleanup. + } +} + +/** + * Acquire the dream marker (`~/.gstack/.dream-in-progress`). Returns false when + * a FRESH marker already exists (another worktree is mid-dream) — the caller + * then SKIPs rather than launching a duplicate ~35-min global job. A stale + * marker (older than DREAM_MARKER_STALE_MS, i.e. a crashed run) is taken over. + * Mirrors acquireLock but with the dream TTL and its own path. + */ +export function acquireDreamMarker(): boolean { + const path = dreamMarkerPath(); + mkdirSync(dirname(path), { recursive: true }); + if (existsSync(path)) { + try { + const stat = statSync(path); + if (Date.now() - stat.mtimeMs > DREAM_MARKER_STALE_MS) { + unlinkSync(path); + } else { + return false; + } + } catch { + return false; + } + } + const info: LockInfo = { pid: process.pid, started_at: new Date().toISOString() }; + try { + writeFileSync(path, JSON.stringify(info), { encoding: "utf-8", flag: "wx" }); + return true; + } catch { + return false; + } +} + +export function releaseDreamMarker(): void { + try { + const path = dreamMarkerPath(); + if (!existsSync(path)) return; + const info = JSON.parse(readFileSync(path, "utf-8")) as LockInfo; + if (info.pid === process.pid) unlinkSync(path); + } catch { + // Best-effort cleanup. + } +} + +/** Read the pid recorded in a fresh dream marker, for the "already running" message. */ +function dreamMarkerPid(): number | null { + try { + const info = JSON.parse(readFileSync(dreamMarkerPath(), "utf-8")) as LockInfo; + return typeof info.pid === "number" ? info.pid : null; + } catch { + return null; + } +} + +// ── Stage runners ────────────────────────────────────────────────────────── + +/** + * Build a SKIP result for the code/memory stage when the local engine is + * not in 'ok' state (per plan D12). Surface the status verbatim so the + * verdict block tells the user exactly what's wrong without re-probing. + * + * Reasons mapped to user-actionable summaries: + * no-cli → "gbrain CLI not on PATH; install via /setup-gbrain" + * missing-config → "no local engine; run /setup-gbrain to add local PGLite" + * broken-config → "config file at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5" + * broken-db → "config points at unreachable DB; see /setup-gbrain Step 1.5" + * engine-locked → PGLite is busy; stop its holder or sync outside the live session + * timeout → kept for Record totality; stages PROCEED on timeout (#1964) + * via the gate's warnProbeTimeout path, never this skip. + * thin-client → remote-HTTP MCP brain, no local engine by design (#2051); + * local sync stages skip (gbrain refuses sources/sync there), + * but suppression gates treat the brain as USABLE. + */ +function skipStageForLocalStatus( + stage: "code" | "memory" | "dream", + status: LocalEngineStatus, + t0: number, +): StageResult { + const reasons: Record, string> = { + "no-cli": "gbrain CLI not on PATH; install via /setup-gbrain", + "missing-config": + "no local engine; run /setup-gbrain to add local PGLite for code search", + "broken-config": + "config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5", + "broken-db": + "config points at unreachable DB; see /setup-gbrain Step 1.5", + "engine-locked": + "PGLite is busy (often held by gbrain serve); stop the holding process or run /sync-gbrain outside the live Claude session, then retry", + "timeout": + "engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow", + "thin-client": + "thin client (remote-HTTP MCP brain, no local engine by design, #2051); " + + "code indexing runs on the brain server, memory syncs via the remote " + + "brain's artifacts pull — nothing to do locally", + }; + const reason = reasons[status as Exclude]; + return { + name: stage, + ran: false, + ok: true, // SKIP (per D12) — not a stage failure, just an unsatisfied prerequisite + duration_ms: Date.now() - t0, + summary: `skipped — local engine ${status} — ${reason}`, + }; +} + +/** + * "timeout" means the probe hit its deadline with no recognized error — the + * engine is most likely healthy but slow (#1964: cold pooler connections + * measured at 6.9-10.7s). Stages proceed; a genuinely-dead engine surfaces + * its REAL error at the first actual operation instead of a false + * "config malformed" skip. + */ +function warnProbeTimeout(stage: "code" | "memory" | "dream"): void { + process.stderr.write( + `[gstack-gbrain-sync] ${stage}: engine probe timed out — proceeding anyway; ` + + `raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow\n`, + ); +} + + +/** + * Per-repo trust tier from ~/.gstack/gbrain-repo-policy.json, read through + * the bin/gstack-gbrain-repo-policy CLI (which owns URL normalization and + * schema migration — do not reimplement either here). + * + * The tier was previously enforced only in /sync-gbrain skill prose, so a + * direct or cron invocation of this script ingested repo code regardless of + * a `deny`/`read-only` setting — and the egress receipt below cited this + * chokepoint as consent before it existed (#2140 sync path). This check + * closes both gaps. + * + * Fail-open ONLY when no policy store exists (nothing was ever set — same + * behavior as before for every non-policy user, and skips the subprocess). + * Fail-closed ("error") when a store exists but can't be read: a policy the + * user set must not be silently bypassed by a broken store or missing jq. + * + * Reads through the shared lib/gbrain-repo-policy-client.ts (same client as + * the code-intelligence consent veto — the two gates can never drift, and + * win32 gets the invoke-via-bash path). A spawn failure is still fail-closed + * but says so, instead of the misleading "store could not be read". + */ +export function repoPolicyTier(url: string | null): "read-write" | "read-only" | "deny" | "unset" | "error" { + const res = sharedRepoPolicyTier(url, process.env); + if (res.error === "spawn-failed") { + process.stderr.write( + "[gstack-gbrain-sync] the repo-policy helper could not be spawned (bash missing from PATH?) — " + + "refusing ingest rather than bypassing a possibly-set policy\n", + ); + return "error"; + } + if (res.error) return "error"; + return res.tier === "none" ? "unset" : res.tier; +} + +async function runCodeImport(args: CliArgs): Promise { + const t0 = Date.now(); + const root = repoRoot(); + if (!root) { + return { name: "code", ran: false, ok: true, duration_ms: 0, summary: "skipped (not in git repo)" }; + } + + // A preview must not spawn gbrain. Trust a syntactically-valid local pin + // there; a real run confirms its registered path before using it. + const gbrainEnv = args.mode === "dry-run" ? undefined : buildGbrainEnv({ announce: !args.quiet }); + const pinnedSourceId = args.mode === "dry-run" + ? readPinnedSourceId(root) + : existingPinnedSourceId(root, gbrainEnv); + const sourceId = pinnedSourceId ?? deriveCodeSourceId(root); + + // Per-repo trust tier — checked BEFORE the dry-run branch so previews report + // the refusal honestly instead of claiming they would sync. + const policyUrl = originUrl(); + const tier = repoPolicyTier(policyUrl); + if (tier === "read-only") { + // Honoring an explicit user setting (search allowed, page writes never) is + // a clean skip, not a stage failure — code ingest writes pages. + return { + name: "code", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: `skipped — repo policy is read-only for ${policyUrl} (code ingest writes pages). Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`, + detail: { source_id: sourceId, source_path: root, status: "skipped-policy-read-only" }, + }; + } + if (tier === "deny" || tier === "error") { + const why = tier === "deny" + ? `repo policy is deny for ${policyUrl} — no gbrain ingest for this repo. Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write` + : "repo policy store exists but could not be read (gstack-gbrain-repo-policy get failed) — refusing ingest rather than bypassing a set policy"; + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `refused: ${why}`, + detail: { source_id: sourceId, source_path: root, status: tier === "deny" ? "refused-policy-deny" : "refused-policy-unreadable" }, + }; + } + + // dry-run preview always shows the would-do steps, regardless of local + // engine state. Useful for "what would /sync-gbrain do" without probing + // the engine. + if (args.mode === "dry-run") { + return { + name: "code", + ran: false, + ok: true, + duration_ms: 0, + summary: pinnedSourceId + ? `would: gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}` + : `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`, + detail: { source_id: sourceId, source_path: root, status: "skipped" }, + }; + } + + // Split-engine pre-flight (per plan D12): when local engine is not ok, SKIP + // code stage cleanly. Brain-sync stage still runs because it doesn't depend + // on local engine. The /sync-gbrain Step 1.5 pre-flight surfaces the user + // remediation message; this skip just keeps the orchestrator from crashing + // when the local DB is dead. Skipped on --dry-run (above) since dry-run + // never actually probes anything. + const localStatus = localEngineStatus({ noCache: false }); + if (localStatus === "timeout") { + warnProbeTimeout("code"); // #1964: slow-but-healthy — proceed + } else if (localStatus !== "ok") { + return skipStageForLocalStatus("code", localStatus, t0); + } + + // Step 0a: Best-effort cleanup of pre-pathhash legacy source (v1.x form). + // Earlier /sync-gbrain versions registered `gstack-code-` (no path + // suffix). On a multi-worktree repo, those collapsed onto a single id + // with last-sync-wins. Federated search would return stale duplicate + // hits forever if we left the orphan in place. Remove the legacy id once + // here so users don't accumulate orphans. + // Failure is non-fatal — we still register the new id below. + // gbrainEnv seeds DATABASE_URL from gbrain's config so this stage works + // inside Next.js / Prisma / Rails projects with their own .env.local + // (codex review #7 — bug fix is wider than #1508 as filed). + const legacyId = deriveLegacyCodeSourceId(root); + let legacyRemoved = false; + if (!pinnedSourceId && legacyId !== sourceId) { + // #1734: route through the data-loss guards (autopilot + source-safety). + const rm = safeSourcesRemove(legacyId, gbrainEnv); + if (rm.skipped && !args.quiet) { + console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`); + } + if (rm.removed) legacyRemoved = true; + } + + // Step 0b: Hostname-fold migration (#1414). + // Before #1468 the source id hashed only the absolute repo path. After the + // hostname fold, every existing user has a legacy id that no longer matches + // what deriveCodeSourceId produces. Try rename-in-place first (preserves + // pages); fall back to register-new → sync-OK → remove-old. Path-drift + // (user moved the repo, etc.) skips migration with a warning. + const pathOnlyHashLegacyId = derivePathOnlyHashLegacyId(root); + const migration = pinnedSourceId + ? { kind: "none", reason: "no-legacy-source" } as const + : planHostnameFoldMigration(root, sourceId, pathOnlyHashLegacyId, gbrainEnv); + if (migration.kind === "skipped-path-drift" && !args.quiet) { + console.error( + `[sync:code] hostname-fold migration skipped: legacy source ${migration.oldId} ` + + `points at ${migration.oldPath}, current repo is ${migration.currentPath}. ` + + `Clean up manually with: gbrain sources remove ${migration.oldId} --confirm-destructive`, + ); + } else if (migration.kind === "renamed" && !args.quiet) { + console.error(`[sync:code] hostname-fold migration: renamed ${migration.oldId} → ${migration.newId} (pages preserved)`); + } + + // Step 1: Ensure generated sources are registered. A confirmed explicit pin + // belongs to the user: its realpath was checked above, so never remove/add it + // merely because the registered spelling differs (e.g. a symlinked checkout). + let registered = false; + if (!pinnedSourceId) { + try { + const result = await ensureSourceRegistered(sourceId, root, { federated: true, env: gbrainEnv }); + registered = result.changed; + } catch (err) { + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `source registration failed: ${(err as Error).message}`, + detail: { source_id: sourceId, source_path: root, status: "failed" }, + }; + } + } + + // Step 2: Always run the page-creating file walk first, then (for --full) + // a full re-embed. + // + // `gbrain reindex-code` only RE-EMBEDS pages that already exist; it never + // walks the filesystem. On a freshly-registered source (0 pages) a --full + // run that called reindex-code alone found nothing ("No code pages to + // reindex"), finished in ~1s, and left the code index permanently empty + // while still reporting OK. The page-creating walk is `sync --strategy + // code`, so --full must run it FIRST, then reindex-code, to honor the + // documented "full walk + reindex" contract for both fresh and populated + // sources. + const codeTimeoutMs = resolveStageTimeoutMs( + process.env.GSTACK_SYNC_CODE_TIMEOUT_MS, + "GSTACK_SYNC_CODE_TIMEOUT_MS", + ); + + // #1734 guards, checked immediately before the destructive walk (E8): + // - autopilot active → refuse (the race that wiped a working tree). + // - URL-managed source → the walk can auto-reclone (rm-rf); require + // --allow-reclone. Both surface a visible reason and fail the stage so the + // verdict shows ERR rather than silently skipping protection. + const apBeforeWalk = detectAutopilot(gbrainEnv); + if (apBeforeWalk.active) { + return { + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`, + detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" }, + }; + } + const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone); + if (!reclone.allow) { + return { + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `refused: ${reclone.reason}`, + detail: { source_id: sourceId, source_path: root, status: "refused-reclone" }, + }; + } + + // Egress receipt BEFORE the code walk (fail-closed): the walk ships repo + // content to the user's gbrain DB, which may be a remote Postgres. The + // gbrain subprocess owns the wire bytes, so the receipt is content-free + // (destination + payload class only; sha256 null). + try { + writeReceipt({ + sink: "gbrain-sync", + host: "gbrain-db (user-configured DATABASE_URL)", + payloadClass: `repo-code-index source=${sourceId} (sent by gbrain subprocess)`, + bytes: 0, + sha256: null, + consent: "gbrain setup consent + per-repo policy chokepoint (repoPolicyTier)", + }); + } catch (err) { + return { + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `EGRESS_RECEIPT_FAILED: ${(err as Error).message} — code sync refused`, + detail: { source_id: sourceId, source_path: root, status: "refused-egress-receipt" }, + }; + } + + // `--full` must do a FULL walk, not a delta one. + // + // A bare `sync --strategy code` is incremental: it only revisits files that + // changed since the source's checkpoint. So a file missed at the ORIGINAL + // import is never revisited and stays invisible indefinitely — and the + // reindex-code pass below cannot rescue it, because it re-chunks pages that + // already exist and never walks the filesystem (the same property the comment + // above already relies on). + // + // The failure is silent: no error, no warning, and the verdict block still + // reports OK while `gbrain search` and `gbrain code-def` answer out of a + // partial index. It presents as "gbrain is weak at code questions" rather + // than "the index is incomplete", which is what makes it hard to spot. + // + // --yes because this is spawned non-interactively; a full walk otherwise + // prompts to confirm the import cost. + const walkArgs = ["sync", "--strategy", "code", "--source", sourceId]; + if (args.mode === "full") walkArgs.push("--full", "--yes"); + const walkResult = spawnGbrain(walkArgs, { + stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], + timeout: codeTimeoutMs, + baseEnv: gbrainEnv, + }); + + if (walkResult.status !== 0) { + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `gbrain ${walkArgs.join(" ")} exited ${walkResult.status}`, + detail: { source_id: sourceId, source_path: root, status: "failed" }, + }; + } + + if (args.mode === "full") { + const reindexResult = spawnGbrain(["reindex-code", "--source", sourceId, "--yes"], { + stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], + timeout: codeTimeoutMs, + baseEnv: gbrainEnv, + }); + + if (reindexResult.status !== 0) { + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `gbrain reindex-code --source ${sourceId} exited ${reindexResult.status}`, + detail: { source_id: sourceId, source_path: root, status: "failed" }, + }; + } + } + + // Step 3: Pin this worktree's CWD to the source via .gbrain-source. Subsequent + // gbrain code-def / code-refs / code-callers calls from anywhere under + // route to this source by default — no --source flag needed. + // + // If attach fails the whole flow has a silent correctness problem: sync + // succeeded but unqualified `gbrain code-def` from this worktree will hit + // the wrong/default source. Treat it as a stage failure (ok=false) so the + // verdict block surfaces ERR and the user knows to retry rather than + // trusting stale results. + const attach = spawnGbrain(["sources", "attach", sourceId], { + timeout: 10_000, + cwd: root, + baseEnv: gbrainEnv, + }); + const pageCount = sourcePageCount(sourceId, gbrainEnv); + + // Step 4: Deferred hostname-fold cleanup. + // Only remove the pre-#1468 path-only-hash source NOW that the new source + // has registered + synced + has pages. Removing before sync would create a + // data-loss window if sync failed; removing without a page-count check would + // wipe pages when sync silently no-op'd. This is the codex-review-flagged + // safety: register → sync → verify → THEN delete. + let hostnameLegacyRemoved = false; + if (migration.kind === "pending-cleanup" && pageCount !== null && pageCount > 0) { + hostnameLegacyRemoved = removeOrphanedSource(migration.oldId, gbrainEnv); + if (hostnameLegacyRemoved && !args.quiet) { + console.error(`[sync:code] hostname-fold migration: removed legacy ${migration.oldId} after new source sync verified (page_count=${pageCount})`); + } + } + + const legacyParts: string[] = []; + if (legacyRemoved) legacyParts.push(`removed legacy ${legacyId}`); + if (migration.kind === "renamed") legacyParts.push(`renamed ${migration.oldId}→${migration.newId}`); + if (hostnameLegacyRemoved) legacyParts.push(`removed pre-hostname-fold ${migration.kind === "pending-cleanup" ? migration.oldId : ""}`); + const legacyNote = legacyParts.length > 0 ? `, ${legacyParts.join(", ")}` : ""; + const baseSummary = `${registered ? "registered + " : ""}synced ${sourceId} (page_count=${pageCount ?? "unknown"}${legacyNote})`; + + if (attach.status !== 0) { + const reason = (attach.stderr || attach.stdout || "").trim().split("\n").pop() || `exit ${attach.status}`; + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `${baseSummary}; attach FAILED (${reason}) — code-def queries from this worktree will hit the default source until /sync-gbrain succeeds`, + detail: { + source_id: sourceId, + source_path: root, + page_count: pageCount, + last_imported: new Date().toISOString(), + status: "failed", + }, + }; + } + + // v1.29.0.0 changelog promised the per-worktree pin would be ignored in the + // consuming repo, but the change actually only added .gbrain-source to + // gstack's own .gitignore. Without the consumer-side entry, the pin gets + // committed and breaks the per-worktree promise: Conductor sibling worktrees + // step on each other's pin every time anyone commits (#1384). + ensureGbrainSourceGitignored(root); + + return { + name: "code", + ran: true, + ok: true, + duration_ms: Date.now() - t0, + summary: baseSummary, + detail: { + source_id: sourceId, + source_path: root, + page_count: pageCount, + last_imported: new Date().toISOString(), + status: "ok", + }, + }; +} + +/** + * Ensure `.gbrain-source` is listed in the consumer repo's `.gitignore`. + * + * Idempotent: only appends when the entry is not already present (matched on + * trimmed lines so a leading/trailing whitespace difference doesn't add a + * second copy). Wraps writes in try/catch so a read-only checkout or weird + * perms logs a warning and lets the rest of the sync continue. + */ +export function ensureGbrainSourceGitignored(root: string): void { + const gitignorePath = join(root, ".gitignore"); + try { + let existing = ""; + try { + existing = readFileSync(gitignorePath, "utf-8"); + } catch { + // No .gitignore yet — we'll create it. + } + const alreadyIgnored = existing + .split("\n") + .some((line) => line.trim() === ".gbrain-source"); + if (alreadyIgnored) { + return; + } + const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; + writeFileSync(gitignorePath, existing + sep + ".gbrain-source\n"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn( + `[sync:code] could not add .gbrain-source to ${gitignorePath}: ${msg}`, + ); + } +} + +function runMemoryIngest(args: CliArgs): StageResult { + const t0 = Date.now(); + + if (args.mode === "dry-run") { + return { name: "memory", ran: false, ok: true, duration_ms: 0, summary: "would: gstack-memory-ingest --probe" }; + } + + // Split-engine pre-flight (per plan D12). gstack-memory-ingest shells out + // to `gbrain import` which targets the LOCAL engine. When that engine is + // not ok, SKIP cleanly so brain-sync (the only stage that doesn't depend + // on local engine) still runs. + const localStatus = localEngineStatus({ noCache: false }); + if (localStatus === "timeout") { + warnProbeTimeout("memory"); // #1964: slow-but-healthy — proceed + } else if (localStatus !== "ok") { + return skipStageForLocalStatus("memory", localStatus, t0); + } + + // Resume detection (#1611 / plan D1 + C1). If a previous run hit the + // timeout and gbrain left ~/.gbrain/import-checkpoint.json plus its staging + // dir on disk, signal the grandchild via env so it skips the prepare phase + // and lets `gbrain import` resume from processedIndex+1 against the same + // staging dir. If the staging dir is gone (disk pressure cleanup, OS + // reboot, user manual cleanup), warn and fall through to a fresh restage. + const resume = decideResume(); + const childEnv = buildGbrainEnv({ announce: false }); + if (resume.kind === "resume") { + console.error( + `[sync:memory] resuming from gbrain checkpoint (${resume.processedIndex}/${resume.totalFiles} files staged at ${resume.stagingDir})`, + ); + childEnv.GSTACK_INGEST_RESUME_DIR = resume.stagingDir; + } else if (resume.kind === "stale-staging-missing") { + // The reason distinguishes "actually gone" (disk cleanup / reboot) from + // "refused as unowned" (#1802 poison: the path may still exist on disk). + // Logging "gone" for a refused poison path misdirects incident diagnosis. + const why = resume.reason + ? `staging dir not usable: ${resume.reason}` + : `staging dir ${resume.stagingDir} gone`; + console.error( + `[sync:memory] previous checkpoint stale (${why}), restaging from scratch. ` + + `Remove ~/.gbrain/import-checkpoint.json to silence.`, + ); + } + + const ingestPath = join(import.meta.dir, "gstack-memory-ingest.ts"); + const ingestArgs = ["run", ingestPath]; + if (args.mode === "full") ingestArgs.push("--bulk"); + else ingestArgs.push("--incremental"); + if (args.quiet) ingestArgs.push("--quiet"); + + // Thread the seeded env into the bun grandchild (codex review #7 — the + // .env.local footgun affects gstack-memory-ingest.ts too, not just the + // direct gbrain spawns in this file). The grandchild calls gbrain import + // internally and must see the DATABASE_URL from gbrain's own config. + const memoryTimeoutMs = resolveStageTimeoutMs( + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS, + "GSTACK_SYNC_MEMORY_TIMEOUT_MS", + ); + const result = spawnSync("bun", ingestArgs, { + encoding: "utf-8", + timeout: memoryTimeoutMs, + env: childEnv, + }); + + // D6: parse [memory-ingest] lines from the child's stderr. ERR-prefixed + // lines indicate a system-level failure (gbrain crashed or CLI missing) + // and the child exits non-zero. Per-file failures are summarized in the + // last non-ERR [memory-ingest] line but do NOT make the verdict ERR. + const stderrLines = (result.stderr || "").split("\n"); + const memLines = stderrLines.filter((l) => l.includes("[memory-ingest]")); + const errLine = memLines.find((l) => l.includes("[memory-ingest] ERR")); + const lastMemLine = memLines.slice(-1)[0]; + const rawSummary = errLine || lastMemLine || "ingest pass complete"; + // Strip the "[memory-ingest] " prefix and any leading "ERR: " for cleaner + // verdict output. The orchestrator's own formatStage will prefix with OK/ERR. + const summary = rawSummary + .replace(/^.*\[memory-ingest\]\s*/, "") + .replace(/^ERR:\s*/, ""); + + const ok = result.status === 0; + return { + name: "memory", + ran: true, + ok, + duration_ms: Date.now() - t0, + summary: ok + ? summary + : `${summary}${result.status === null ? " (killed by signal / timeout)" : ` (exit ${result.status})`}`, + }; +} + +function runBrainSyncPush(args: CliArgs): StageResult { + const t0 = Date.now(); + + if (args.mode === "dry-run") { + return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "would: gstack-brain-sync --discover-new --once" }; + } + + const brainSyncPath = join(import.meta.dir, "gstack-brain-sync"); + if (!existsSync(brainSyncPath)) { + return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" }; + } + + // gstack-brain-sync is a bash shebang script, so it needs an INTERPRETER, not + // a shell. #1731 gave it `shell: NEEDS_SHELL_ON_WINDOWS`, which is right for + // the gbrain.cmd shim and useless here: cmd.exe resolves .cmd/.bat via PATHEXT + // and rejects an extension-less shebang script outright ("is not recognized as + // an internal or external command"), so this stage failed on EVERY Windows run + // while looking like a single red line in an otherwise green report. See + // bashScriptInvocation. + const discover = bashScriptInvocation(brainSyncPath, ["--discover-new"]); + const once = bashScriptInvocation(brainSyncPath, ["--once"]); + if (!discover || !once) { + return { + name: "brain-sync", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: "skipped (no bash found; set GSTACK_BASH to your Git bash.exe)", + }; + } + + const stdio: "ignore"[] | ("ignore" | "inherit")[] = args.quiet + ? ["ignore", "ignore", "ignore"] + : ["ignore", "inherit", "inherit"]; + + spawnSync(discover.cmd, discover.argv, { stdio, timeout: 60 * 1000, shell: discover.shell }); + const result = spawnSync(once.cmd, once.argv, { stdio, timeout: 60 * 1000, shell: once.shell }); + + return { + name: "brain-sync", + ran: true, + ok: result.status === 0, + duration_ms: Date.now() - t0, + summary: result.status === 0 ? "curated artifacts pushed" : `gstack-brain-sync exited ${result.status}`, + }; +} + +/** + * Decide whether the dream (call-graph build) cycle should run. PURE so the + * gate matrix is unit-testable without spawning a real ~35-min dream. + * + * - explicit --dream → always run (force), regardless of cycle state / --no-code. + * - --full → run ONLY when the call graph was never built (cycle === "never"), + * and only when not opted out via --no-dream / --no-code. "completed" skips + * (edges already built); "unknown" skips (a flaky doctor must not trigger a + * surprise 35-min cycle — see gbrain-doctor-overstrict). + * - everything else → skip. + * + * `cycle` is only consulted on the --full auto path; pass null when forcing. + */ +export function shouldRunDream(args: CliArgs, cycle: CycleStatus | null): boolean { + if (args.dream) return true; + if (args.mode === "full" && !args.noDream && !args.noCode) { + return cycle === "never"; + } + return false; +} + +/** + * Run `gbrain dream` — the brain-global maintenance cycle whose + * resolve_symbol_edges phase builds the call graph. Runs LOCK-FREE (called + * after the sync lock releases) so it never freezes sibling worktrees; the + * `.dream-in-progress` marker dedupes concurrent dreams instead. + * + * Returns a StageResult (never throws). SKIP (ran:false, ok:true) for: dry-run + * preview, local engine not ok, or a fresh marker present. ERR (ran:true, + * ok:false) for: non-zero/timeout exit, or a spawn-setup failure (missing + * binary / malformed env) — a broken install must be visible, not disguised as + * optional maintenance. + */ +export async function runDream(args: CliArgs): Promise { + const t0 = Date.now(); + + if (args.mode === "dry-run") { + const root = repoRoot(); + const sourceId = root ? readPinnedSourceId(root) ?? deriveCodeSourceId(root) : null; + return { + name: "dream", + ran: false, + ok: true, + duration_ms: 0, + summary: sourceId + ? `would: gbrain dream --source ${sourceId} (build this source's call graph)` + : "would: gbrain dream (call-graph build)", + }; + } + + const gbrainEnv = buildGbrainEnv({ announce: !args.quiet }); + const localStatus = localEngineStatus({ noCache: false }); + if (localStatus === "timeout") { + warnProbeTimeout("dream"); // #1964: slow-but-healthy — proceed + } else if (localStatus !== "ok") { + return skipStageForLocalStatus("dream", localStatus, t0); + } + + // Dedupe concurrent dreams across worktrees (lock-free path). + if (!acquireDreamMarker()) { + const pid = dreamMarkerPid(); + return { + name: "dream", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: `dream already running${pid !== null ? ` (pid ${pid})` : ""} — skipped`, + }; + } + + try { + const dreamTimeoutMs = resolveStageTimeoutMs( + process.env.GSTACK_SYNC_DREAM_TIMEOUT_MS, + "GSTACK_SYNC_DREAM_TIMEOUT_MS", + DEFAULT_DREAM_TIMEOUT_MS, + ); + + // Scope the cycle to THIS worktree's code source: `gbrain dream --source `. + // Verified empirically (not just from `gbrain --help`): plain `gbrain dream` + // cycles the brain's default source and never runs the source-scoped `extract` + // phase for our code source, so the call graph for the pinned source stays + // empty. `gbrain dream --source ` runs the per-source cycle (the form + // `gbrain doctor` recommends for stale sources) and is what actually populates + // code-callers/code-callees for this worktree. Falls back to plain `dream` + // only when we can't derive the source id (not in a git repo). + const root = repoRoot(); + const sourceId = root ? resolveCodeSourceId(root, gbrainEnv) : null; + const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"]; + + // spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv. + // + // We CAPTURE output (pipe) rather than inherit because `gbrain dream` exits 0 + // even when it SKIPS the cycle — when another cycle already holds gbrain's own + // DB lock (e.g. a running `gbrain autopilot`), it prints "Skipped: another + // cycle is already running. (locked)" and exits 0. Trusting the exit code + // alone would falsely report "call graph built". Trade-off: no live streaming + // for a long cycle; we echo the captured output afterward instead. + if (!args.quiet) { + process.stderr.write("[dream] running gbrain cycle (call-graph build; this can take a few minutes)...\n"); + } + let result: ReturnType; + try { + result = spawnGbrain(dreamArgs, { + stdio: ["ignore", "pipe", "pipe"], + timeout: dreamTimeoutMs, + baseEnv: process.env, + announce: !args.quiet, + }); + } catch (err) { + // Spawn-setup failure (missing binary, bad env): ERR, not a benign skip. + return { + name: "dream", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `gbrain dream failed to start: ${(err as Error).message}`, + }; + } + + if (result.error) { + const e = result.error as NodeJS.ErrnoException; + const why = e.code === "ENOENT" ? "gbrain not on PATH" : e.message; + return { + name: "dream", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `gbrain dream failed to start: ${why}`, + }; + } + + const out = `${result.stdout || ""}${result.stderr || ""}`; + if (!args.quiet && out.trim()) { + process.stderr.write(out.endsWith("\n") ? out : `${out}\n`); + } + + if (result.status !== 0) { + return { + name: "dream", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `gbrain dream exited ${result.status === null ? "null (killed by signal / timeout)" : result.status}`, + }; + } + + // Exit 0 but the cycle was SKIPPED because gbrain's own lock is held by + // another cycle (typically `gbrain autopilot`). Report SKIP, not "built" — + // the graph builds on that other cycle, not this invocation. + if (/already running|\block(?:ed)?\b|Skipped:/i.test(out)) { + return { + name: "dream", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: "skipped — a gbrain cycle is already running (e.g. autopilot); the call graph builds on that cycle", + }; + } + + // Exit 0 and the cycle actually ran. Parse the cycle's OWN output to report + // the truth, not a flat "built": `gbrain dream` exits 0 even when the call + // graph could not be built, and a misleading "built" turns a multi-minute + // no-op into a silent dead end. gbrain only surfaces these conditions in the + // cycle log (there is no pre-flight pack-capability query as of 0.41.x), so + // string-matching the log is the available signal; an unrecognized log + // degrades to the generic success summary below. + const dreamWarn = classifyDreamOutcome(out); + if (dreamWarn) { + return { + name: "dream", + ran: true, + ok: true, + warn: true, + duration_ms: Date.now() - t0, + summary: dreamWarn, + }; + } + + const edges = parseResolvedEdges(out); + return { + name: "dream", + ran: true, + ok: true, + duration_ms: Date.now() - t0, + summary: + edges !== null + ? `call graph built (${edges} edge${edges === 1 ? "" : "s"} resolved)` + : "call graph built (resolve_symbol_edges complete)", + }; + } finally { + releaseDreamMarker(); + } +} + +/** + * Parse `` from a `resolve_symbol_edges ... resolved ` cycle-log line. + * Returns null when the line is absent (older gbrain / different pack). The + * `[^\n]*?` is newline-bounded so it matches the `✓ resolve_symbol_edges ...` + * summary line, not the bracketed `[cycle.resolve_symbol_edges] start` markers. + */ +export function parseResolvedEdges(out: string): number | null { + const m = out.match(/resolve_symbol_edges\b[^\n]*?\bresolved\s+(\d+)/i); + return m ? parseInt(m[1], 10) : null; +} + +/** + * Inspect a completed (exit-0) `gbrain dream` log and return a WARN summary when + * the cycle ran but could not actually build the call graph. Returns null on the + * happy path (caller emits the normal "call graph built" summary). Order matters: + * the pack-capability gap is the most actionable, so it wins over a 0-edge count + * (both appear together when the pack lacks the code-symbol phase). + */ +export function classifyDreamOutcome(out: string): string | null { + // The active schema pack doesn't declare the code-symbol extraction phase, so + // no symbols are extracted and resolve_symbol_edges has nothing to match. + // #2341: anchor the match to a GRAPH phase. The bare phrase false-positived + // on every base-pack brain — gbrain's only emitters of "active pack does not + // declare this phase" are the CONTENT phases (extract_atoms, + // synthesize_concepts), which base packs legitimately skip while + // resolve_symbol_edges still runs and builds the graph. Matching the bare + // phrase sent users pack-churning ("switch schema packs") for nothing and + // masked real graph bugs behind a wrong diagnosis. + if (/(resolve_symbol_edges|extract_code_symbols)[^\n]*does not declare/i.test(out)) { + return ( + "dream ran, but this source's schema pack does not extract code symbols, " + + "so the call graph stays empty. Switch this source to a code-aware schema " + + "pack (`gbrain schema use `) to enable code-callers/code-callees." + ); + } + // The embed phase failed for a missing key; symbols can't index without it. + if (/embed phase failed/i.test(out) || /requires\s+\S*_API_KEY/i.test(out)) { + return ( + "dream ran, but the embed phase failed (missing embedding API key), so " + + "symbols won't index. Ensure the embedding provider's key is set for the " + + "gbrain process, then re-run /sync-gbrain --dream." + ); + } + // Cycle ran and embedded fine, but matched zero call-graph edges. + if (parseResolvedEdges(out) === 0) { + return "dream ran but resolved 0 call-graph edges (no code symbols matched for this source yet)."; + } + return null; +} + +// ── State file ───────────────────────────────────────────────────────────── + +interface SyncState { + schema_version: 1; + last_writer: string; + last_sync?: string; + last_full_sync?: string; + last_stages?: StageResult[]; +} + +function loadSyncState(): SyncState { + if (!existsSync(STATE_PATH)) { + return { schema_version: 1, last_writer: "gstack-gbrain-sync" }; + } + try { + const raw = JSON.parse(readFileSync(STATE_PATH, "utf-8")) as SyncState; + if (raw.schema_version === 1) return raw; + } catch { + // fall through + } + return { schema_version: 1, last_writer: "gstack-gbrain-sync" }; +} + +/** + * Atomic state file write per /plan-eng-review D1: write tmp file then rename. + * rename(2) is atomic on POSIX filesystems. + */ +function saveSyncState(state: SyncState): void { + try { + mkdirSync(dirname(STATE_PATH), { recursive: true }); + const tmp = `${STATE_PATH}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8"); + renameSync(tmp, STATE_PATH); + } catch { + // non-fatal + } +} + +/** + * Persist the dream stage result with read-modify-write semantics. + * + * Dream runs AFTER the sync lock releases, so a sibling worktree may have + * written newer state in the meantime. Overwriting the whole file with our + * pre-dream snapshot + dream result would clobber that sibling's sync. Instead + * re-read the CURRENT state, replace only the `dream` entry in last_stages, and + * atomic-rename. (Atomic rename alone isn't race-safe; the re-read + targeted + * merge is what prevents the clobber.) + */ +function mergeDreamIntoState(dream: StageResult): void { + const fresh = loadSyncState(); + const others = (fresh.last_stages || []).filter((s) => s.name !== "dream"); + fresh.last_stages = [...others, dream]; + fresh.last_sync = new Date().toISOString(); + saveSyncState(fresh); +} + +// ── Output ───────────────────────────────────────────────────────────────── + +export function formatStage(s: StageResult): string { + const status = !s.ran ? "SKIP" : !s.ok ? "ERR" : s.warn ? "WARN" : "OK"; + const dur = s.duration_ms > 0 ? ` (${(s.duration_ms / 1000).toFixed(1)}s)` : ""; + return ` ${status.padEnd(5)} ${s.name.padEnd(12)} ${s.summary}${dur}`; +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const args = parseArgs(); + + if (!args.quiet) { + const engine = detectEngineTier(); + console.error(`[gbrain-sync] mode=${args.mode} engine=${engine.engine}`); + } + + // Acquire lock (skip on dry-run since dry-run never writes). + const needsLock = args.mode !== "dry-run"; + let haveLock = false; + if (needsLock) { + haveLock = acquireLock(); + if (!haveLock) { + console.error( + `[gbrain-sync] another /sync-gbrain is running (lock at ${LOCK_PATH}). ` + + `If that process died, the lock auto-clears after 5 min, or remove it manually.` + ); + process.exit(2); + } + } + + const cleanup = () => { + if (haveLock) releaseLock(); + }; + process.on("SIGINT", () => { cleanup(); process.exit(130); }); + process.on("SIGTERM", () => { cleanup(); process.exit(143); }); + + let exitCode = 0; + const stages: StageResult[] = []; + try { + const state = loadSyncState(); + + if (!args.noCode) { + stages.push(await withErrorContext("sync:code", () => runCodeImport(args), "gstack-gbrain-sync")); + } + if (!args.noMemory) { + stages.push(await withErrorContext("sync:memory", () => runMemoryIngest(args), "gstack-gbrain-sync")); + } + if (!args.noBrainSync) { + stages.push(await withErrorContext("sync:brain-sync", () => runBrainSyncPush(args), "gstack-gbrain-sync")); + } + + if (args.mode !== "dry-run") { + state.last_sync = new Date().toISOString(); + if (args.mode === "full") state.last_full_sync = state.last_sync; + state.last_stages = stages; + saveSyncState(state); + } + + const anyError = stages.some((s) => s.ran && !s.ok); + exitCode = anyError ? 1 : 0; + } finally { + // Release the sync lock BEFORE the dream cycle. Dream is a source-scoped + // cycle that can run several minutes; holding the machine-wide lock that + // long would freeze every other worktree's /sync-gbrain. Dream is guarded + // by its own marker. + cleanup(); + } + + // ── Dream (call-graph build) — LOCK-FREE, after the sync lock releases ───── + let dreamStage: StageResult | null = null; + if (args.mode === "dry-run") { + // Preview only; never probes doctor or spawns. `--dry-run` and `--full` are + // mutually exclusive modes (last one wins in parseArgs), so the only dream + // preview that applies to a dry-run is the explicit --dream force. + if (args.dream) { + dreamStage = await runDream(args); + } + } else { + // Resolve cycle state only on the --full auto path (perf: the steady-state + // incremental sync never pays a doctor subprocess). Explicit --dream forces. + let cycle: CycleStatus | null = null; + if (!args.dream && args.mode === "full" && !args.noDream && !args.noCode) { + const root = repoRoot(); + const gbrainEnv = buildGbrainEnv({ announce: !args.quiet }); + cycle = root ? cycleCompleted(resolveCodeSourceId(root, gbrainEnv), gbrainEnv) : "unknown"; + } + if (shouldRunDream(args, cycle)) { + dreamStage = await runDream(args); + mergeDreamIntoState(dreamStage); + if (dreamStage.ran && !dreamStage.ok) exitCode = 1; + } else if (cycle === "unknown") { + // --full wanted to auto-build but doctor couldn't confirm the graph state. + // Surface a WARN-style SKIP so the user knows to run --dream if needed, + // rather than silently doing nothing (a flaky doctor must not trigger a + // surprise 35-min run — gbrain-doctor-overstrict). + dreamStage = { + name: "dream", + ran: false, + ok: true, + duration_ms: 0, + summary: "call-graph state unknown (doctor unavailable) — run /sync-gbrain --dream if code-callers returns 0", + }; + } + } + + if (!args.quiet || args.mode === "dry-run") { + const allStages = dreamStage ? [...stages, dreamStage] : stages; + console.log(`\ngstack-gbrain-sync (${args.mode}):`); + for (const s of allStages) console.log(formatStage(s)); + const okCount = allStages.filter((s) => s.ok).length; + const errCount = allStages.filter((s) => !s.ok && s.ran).length; + console.log(`\n ${okCount} ok, ${errCount} error, ${allStages.length - okCount - errCount} skipped`); + } + + process.exit(exitCode); +} + +if (import.meta.main) { + main().catch((err) => { + console.error(`gstack-gbrain-sync fatal: ${err instanceof Error ? err.message : String(err)}`); + releaseLock(); + process.exit(1); + }); +} diff --git a/.agents/skills/gstack/bin/gstack-global-discover.ts b/.agents/skills/gstack/bin/gstack-global-discover.ts new file mode 100644 index 0000000..79189e4 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-global-discover.ts @@ -0,0 +1,609 @@ +#!/usr/bin/env bun +/** + * gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, and Gemini CLI. + * Resolves each session's working directory to a git repo, deduplicates by normalized remote URL, + * and outputs structured JSON to stdout. + * + * Usage: + * gstack-global-discover --since 7d [--format json|summary] + * gstack-global-discover --help + */ + +import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs"; +import { join, basename } from "path"; +import { execSync } from "child_process"; +import { homedir } from "os"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface Session { + tool: "claude_code" | "codex" | "gemini"; + cwd: string; +} + +interface Repo { + name: string; + remote: string; + paths: string[]; + sessions: { claude_code: number; codex: number; gemini: number }; +} + +interface DiscoveryResult { + window: string; + start_date: string; + repos: Repo[]; + tools: { + claude_code: { total_sessions: number; repos: number }; + codex: { total_sessions: number; repos: number }; + gemini: { total_sessions: number; repos: number }; + }; + total_sessions: number; + total_repos: number; +} + +// ── CLI parsing ──────────────────────────────────────────────────────────── + +function printUsage(): void { + console.error(`Usage: gstack-global-discover --since [--format json|summary] + + --since Time window: e.g. 7d, 14d, 30d, 24h + --format Output format: json (default) or summary + --help Show this help + +Examples: + gstack-global-discover --since 7d + gstack-global-discover --since 14d --format summary`); +} + +function parseArgs(): { since: string; format: "json" | "summary" } { + const args = process.argv.slice(2); + let since = ""; + let format: "json" | "summary" = "json"; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--help" || args[i] === "-h") { + printUsage(); + process.exit(0); + } else if (args[i] === "--since" && args[i + 1]) { + since = args[++i]; + } else if (args[i] === "--format" && args[i + 1]) { + const f = args[++i]; + if (f !== "json" && f !== "summary") { + console.error(`Invalid format: ${f}. Use 'json' or 'summary'.`); + printUsage(); + process.exit(1); + } + format = f; + } else { + console.error(`Unknown argument: ${args[i]}`); + printUsage(); + process.exit(1); + } + } + + if (!since) { + console.error("Error: --since is required."); + printUsage(); + process.exit(1); + } + + if (!/^\d+(d|h|w)$/.test(since)) { + console.error(`Invalid window format: ${since}. Use e.g. 7d, 24h, 2w.`); + process.exit(1); + } + + return { since, format }; +} + +function windowToDate(window: string): Date { + const match = window.match(/^(\d+)(d|h|w)$/); + if (!match) throw new Error(`Invalid window: ${window}`); + const [, numStr, unit] = match; + const num = parseInt(numStr, 10); + const now = new Date(); + + if (unit === "h") { + return new Date(now.getTime() - num * 60 * 60 * 1000); + } else if (unit === "w") { + // weeks — midnight-aligned like days + const d = new Date(now); + d.setDate(d.getDate() - num * 7); + d.setHours(0, 0, 0, 0); + return d; + } else { + // days — midnight-aligned + const d = new Date(now); + d.setDate(d.getDate() - num); + d.setHours(0, 0, 0, 0); + return d; + } +} + +// ── URL normalization ────────────────────────────────────────────────────── + +export function normalizeRemoteUrl(url: string): string { + let normalized = url.trim(); + + // SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo + const sshMatch = normalized.match(/^(?:ssh:\/\/)?git@([^:]+):(.+)$/); + if (sshMatch) { + normalized = `https://${sshMatch[1]}/${sshMatch[2]}`; + } + + // Strip .git suffix + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + + // Lowercase the host portion + try { + const parsed = new URL(normalized); + parsed.hostname = parsed.hostname.toLowerCase(); + normalized = parsed.toString(); + // Remove trailing slash + if (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + } catch { + // Not a valid URL (e.g., local:), return as-is + } + + return normalized; +} + +// ── Git helpers ──────────────────────────────────────────────────────────── + +function isGitRepo(dir: string): boolean { + return existsSync(join(dir, ".git")); +} + +function getGitRemote(cwd: string): string | null { + if (!existsSync(cwd) || !isGitRepo(cwd)) return null; + try { + const remote = execSync("git remote get-url origin", { + cwd, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return remote || null; + } catch (err: any) { + // Expected: no remote configured, repo not found, git not installed + if (err?.status !== undefined) return null; // non-zero exit from git + if (err?.code === 'ENOENT') return null; // git binary not found + throw err; + } +} + +// ── Scanners ─────────────────────────────────────────────────────────────── + +function scanClaudeCode(since: Date): Session[] { + const projectsDir = join(homedir(), ".claude", "projects"); + if (!existsSync(projectsDir)) return []; + + const sessions: Session[] = []; + + let dirs: string[]; + try { + dirs = readdirSync(projectsDir); + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'EACCES') return []; + throw err; + } + + for (const dirName of dirs) { + const dirPath = join(projectsDir, dirName); + try { + const stat = statSync(dirPath); + if (!stat.isDirectory()) continue; + } catch { + continue; + } + + // Find JSONL files + let jsonlFiles: string[]; + try { + jsonlFiles = readdirSync(dirPath).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + if (jsonlFiles.length === 0) continue; + + // Coarse mtime pre-filter: check if any JSONL file is recent + const hasRecentFile = jsonlFiles.some((f) => { + try { + return statSync(join(dirPath, f)).mtime >= since; + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'EACCES') return false; + throw err; + } + }); + if (!hasRecentFile) continue; + + // Resolve cwd + let cwd = resolveClaudeCodeCwd(dirPath, dirName, jsonlFiles); + if (!cwd) continue; + + // Count only JSONL files modified within the window as sessions + const recentFiles = jsonlFiles.filter((f) => { + try { + return statSync(join(dirPath, f)).mtime >= since; + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'EACCES') return false; + throw err; + } + }); + for (let i = 0; i < recentFiles.length; i++) { + sessions.push({ tool: "claude_code", cwd }); + } + } + + return sessions; +} + +function resolveClaudeCodeCwd( + dirPath: string, + dirName: string, + jsonlFiles: string[] +): string | null { + // Fast-path: decode directory name + // e.g., -Users-garrytan-git-repo → /Users/garrytan/git/repo + const decoded = dirName.replace(/^-/, "/").replace(/-/g, "/"); + if (existsSync(decoded)) return decoded; + + // Fallback: read cwd from first JSONL file + // Sort by mtime descending, pick most recent + const sorted = jsonlFiles + .map((f) => { + try { + return { name: f, mtime: statSync(join(dirPath, f)).mtime.getTime() }; + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'EACCES') return null; + throw err; + } + }) + .filter(Boolean) + .sort((a, b) => b!.mtime - a!.mtime) as { name: string; mtime: number }[]; + + for (const file of sorted.slice(0, 3)) { + const cwd = extractCwdFromJsonl(join(dirPath, file.name)); + if (cwd && existsSync(cwd)) return cwd; + } + + return null; +} + +export function extractCwdFromJsonl(filePath: string): string | null { + // Read a capped prefix so huge JSONL files don't blow up memory. 64KB + // comfortably fits the largest observed session headers; the old 8KB cap + // would sometimes fall inside a single long line and silently drop the + // project (JSON.parse failure on the truncated tail). + const MAX_BYTES = 64 * 1024; + const MAX_LINES = 30; + try { + const fd = openSync(filePath, "r"); + const buf = Buffer.alloc(MAX_BYTES); + const bytesRead = readSync(fd, buf, 0, MAX_BYTES, 0); + closeSync(fd); + const text = buf.toString("utf-8", 0, bytesRead); + // Drop the final segment — it may be an incomplete line at the cap boundary. + const parts = text.split("\n"); + const completeLines = parts.length > 1 ? parts.slice(0, -1) : parts; + for (const line of completeLines.slice(0, MAX_LINES)) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.cwd) return obj.cwd; + } catch { + continue; + } + } + } catch { + // File read error + } + return null; +} + +function scanCodex(since: Date): Session[] { + const sessionsDir = process.env.CODEX_SESSIONS_DIR || join(homedir(), ".codex", "sessions"); + if (!existsSync(sessionsDir)) return []; + + const sessions: Session[] = []; + + // Walk YYYY/MM/DD directory structure + try { + const years = readdirSync(sessionsDir); + for (const year of years) { + const yearPath = join(sessionsDir, year); + if (!statSync(yearPath).isDirectory()) continue; + + const months = readdirSync(yearPath); + for (const month of months) { + const monthPath = join(yearPath, month); + if (!statSync(monthPath).isDirectory()) continue; + + const days = readdirSync(monthPath); + for (const day of days) { + const dayPath = join(monthPath, day); + if (!statSync(dayPath).isDirectory()) continue; + + const files = readdirSync(dayPath).filter((f) => + f.startsWith("rollout-") && f.endsWith(".jsonl") + ); + + for (const file of files) { + const filePath = join(dayPath, file); + try { + const stat = statSync(filePath); + if (stat.mtime < since) continue; + } catch { + continue; + } + + // Codex session_meta lines embed the full system prompt in + // base_instructions (~15KB as of CLI v0.117+). A 4KB buffer + // truncates the line and JSON.parse fails. 128KB covers current + // sizes with room for growth. + try { + const fd = openSync(filePath, "r"); + const buf = Buffer.alloc(131072); + const bytesRead = readSync(fd, buf, 0, 131072, 0); + closeSync(fd); + const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0]; + if (!firstLine) continue; + const meta = JSON.parse(firstLine); + if (meta.type === "session_meta" && meta.payload?.cwd) { + sessions.push({ tool: "codex", cwd: meta.payload.cwd }); + } + } catch { + console.error(`Warning: could not parse Codex session ${filePath}`); + } + } + } + } + } + } catch { + // Directory read error + } + + return sessions; +} + +function scanGemini(since: Date): Session[] { + const tmpDir = join(homedir(), ".gemini", "tmp"); + if (!existsSync(tmpDir)) return []; + + // Load projects.json for path mapping + const projectsPath = join(homedir(), ".gemini", "projects.json"); + let projectsMap: Record = {}; // name → path + if (existsSync(projectsPath)) { + try { + const data = JSON.parse(readFileSync(projectsPath, { encoding: "utf-8" })); + // Format: { projects: { "/path": "name" } } — we want name → path + const projects = data.projects || {}; + for (const [path, name] of Object.entries(projects)) { + projectsMap[name as string] = path; + } + } catch { + console.error("Warning: could not parse ~/.gemini/projects.json"); + } + } + + const sessions: Session[] = []; + const seenTimestamps = new Map>(); // projectName → Set + + let projectDirs: string[]; + try { + projectDirs = readdirSync(tmpDir); + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'EACCES') return []; + throw err; + } + + for (const projectName of projectDirs) { + const chatsDir = join(tmpDir, projectName, "chats"); + if (!existsSync(chatsDir)) continue; + + // Resolve cwd from projects.json + let cwd = projectsMap[projectName] || null; + + // Fallback: check .project_root + if (!cwd) { + const projectRootFile = join(tmpDir, projectName, ".project_root"); + if (existsSync(projectRootFile)) { + try { + cwd = readFileSync(projectRootFile, { encoding: "utf-8" }).trim(); + } catch {} + } + } + + if (!cwd || !existsSync(cwd)) continue; + + const seen = seenTimestamps.get(projectName) || new Set(); + seenTimestamps.set(projectName, seen); + + let files: string[]; + try { + files = readdirSync(chatsDir).filter((f) => + f.startsWith("session-") && f.endsWith(".json") + ); + } catch { + continue; + } + + for (const file of files) { + const filePath = join(chatsDir, file); + try { + const stat = statSync(filePath); + if (stat.mtime < since) continue; + } catch { + continue; + } + + try { + const data = JSON.parse(readFileSync(filePath, { encoding: "utf-8" })); + const startTime = data.startTime || ""; + + // Deduplicate by startTime within project + if (startTime && seen.has(startTime)) continue; + if (startTime) seen.add(startTime); + + sessions.push({ tool: "gemini", cwd }); + } catch { + console.error(`Warning: could not parse Gemini session ${filePath}`); + } + } + } + + return sessions; +} + +// ── Deduplication ────────────────────────────────────────────────────────── + +async function resolveAndDeduplicate(sessions: Session[]): Promise { + // Group sessions by cwd + const byCwd = new Map(); + for (const s of sessions) { + const existing = byCwd.get(s.cwd) || []; + existing.push(s); + byCwd.set(s.cwd, existing); + } + + // Resolve git remotes for each cwd + const cwds = Array.from(byCwd.keys()); + const remoteMap = new Map(); // cwd → normalized remote + + for (const cwd of cwds) { + const raw = getGitRemote(cwd); + if (raw) { + remoteMap.set(cwd, normalizeRemoteUrl(raw)); + } else if (existsSync(cwd) && isGitRepo(cwd)) { + remoteMap.set(cwd, `local:${cwd}`); + } + } + + // Group by normalized remote + const byRemote = new Map(); + for (const [cwd, cwdSessions] of byCwd) { + const remote = remoteMap.get(cwd); + if (!remote) continue; + + const existing = byRemote.get(remote) || { paths: [], sessions: [] }; + if (!existing.paths.includes(cwd)) existing.paths.push(cwd); + existing.sessions.push(...cwdSessions); + byRemote.set(remote, existing); + } + + // Build Repo objects + const repos: Repo[] = []; + for (const [remote, data] of byRemote) { + // Find first valid path + const validPath = data.paths.find((p) => existsSync(p) && isGitRepo(p)); + if (!validPath) continue; + + // Derive name from remote URL + let name: string; + if (remote.startsWith("local:")) { + name = basename(remote.replace("local:", "")); + } else { + try { + const url = new URL(remote); + name = basename(url.pathname); + } catch { + name = basename(remote); + } + } + + const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 }; + for (const s of data.sessions) { + sessionCounts[s.tool]++; + } + + repos.push({ + name, + remote, + paths: data.paths, + sessions: sessionCounts, + }); + } + + // Sort by total sessions descending + repos.sort( + (a, b) => + b.sessions.claude_code + b.sessions.codex + b.sessions.gemini - + (a.sessions.claude_code + a.sessions.codex + a.sessions.gemini) + ); + + return repos; +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + const { since, format } = parseArgs(); + const sinceDate = windowToDate(since); + const startDate = sinceDate.toISOString().split("T")[0]; + + // Run all scanners + const ccSessions = scanClaudeCode(sinceDate); + const codexSessions = scanCodex(sinceDate); + const geminiSessions = scanGemini(sinceDate); + + const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions]; + + // Summary to stderr + console.error( + `Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions` + ); + + // Deduplicate + const repos = await resolveAndDeduplicate(allSessions); + + console.error(`→ ${repos.length} unique repos`); + + // Count per-tool repo counts + const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size; + const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size; + const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size; + + const result: DiscoveryResult = { + window: since, + start_date: startDate, + repos, + tools: { + claude_code: { total_sessions: ccSessions.length, repos: ccRepos }, + codex: { total_sessions: codexSessions.length, repos: codexRepos }, + gemini: { total_sessions: geminiSessions.length, repos: geminiRepos }, + }, + total_sessions: allSessions.length, + total_repos: repos.length, + }; + + if (format === "json") { + console.log(JSON.stringify(result, null, 2)); + } else { + // Summary format + console.log(`Window: ${since} (since ${startDate})`); + console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`); + console.log(`Repos: ${repos.length} unique`); + console.log(""); + for (const repo of repos) { + const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini; + const tools = []; + if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`); + if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex}`); + if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`); + console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`); + console.log(` Remote: ${repo.remote}`); + console.log(` Paths: ${repo.paths.join(", ")}`); + } + } +} + +// Only run main when executed directly (not when imported for testing) +if (import.meta.main) { + main().catch((err) => { + console.error(`Fatal error: ${err.message}`); + process.exit(1); + }); +} diff --git a/.agents/skills/gstack/bin/gstack-ios-qa-daemon b/.agents/skills/gstack/bin/gstack-ios-qa-daemon new file mode 100755 index 0000000..b0ca2c6 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-ios-qa-daemon @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# gstack-ios-qa-daemon — Mac-side daemon that brokers tailnet/loopback traffic +# to a connected iPhone running the in-app StateServer over the CoreDevice USB +# tunnel. Single-instance via flock on ~/.gstack/ios-qa-daemon.pid. +# +# Usage: +# gstack-ios-qa-daemon # loopback-only (local USB) +# gstack-ios-qa-daemon --tailnet # additionally open tailnet listener +# +# Environment: +# GSTACK_IOS_DAEMON_PORT — loopback listener port (default 9099) +# GSTACK_IOS_TARGET_UDID — target iOS device UDID (optional; otherwise +# the first paired connected device is used) +# GSTACK_IOS_TARGET_BUNDLE_ID — bundle ID of the iOS app hosting StateServer +# (default com.gstack.iosqa.fixture) +# +# Readiness protocol: prints `READY: port= pid=` to stdout once both +# listeners are bound. Spawners read stdin with a ~5s timeout to confirm. +# +# Exits cleanly when no active loopback clients are connected AND no remote +# session tokens are outstanding. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENTRY="$GSTACK_DIR/ios-qa/daemon/src/index.ts" + +if [ ! -f "$ENTRY" ]; then + echo "gstack-ios-qa-daemon: missing $ENTRY (gstack install incomplete?)" >&2 + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "gstack-ios-qa-daemon: bun runtime not on PATH — install from https://bun.sh" >&2 + exit 1 +fi + +exec bun run "$ENTRY" "$@" diff --git a/.agents/skills/gstack/bin/gstack-ios-qa-mint b/.agents/skills/gstack/bin/gstack-ios-qa-mint new file mode 100755 index 0000000..ecebaa0 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-ios-qa-mint @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# gstack-ios-qa-mint — manage the tailnet allowlist for remote iOS QA agents. +# +# This is the owner-grant path: it writes identities into the local allowlist +# so a remote agent on the tailnet can self-service mint a session token via +# POST /auth/mint against the daemon. +# +# Run `gstack-ios-qa-mint --help` for full usage. +# +# Allowlist file: ~/.gstack/ios-qa-allowlist.json (mode 0600). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENTRY="$GSTACK_DIR/ios-qa/daemon/src/cli-mint.ts" + +if [ ! -f "$ENTRY" ]; then + echo "gstack-ios-qa-mint: missing $ENTRY (gstack install incomplete?)" >&2 + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "gstack-ios-qa-mint: bun runtime not on PATH — install from https://bun.sh" >&2 + exit 1 +fi + +exec bun run "$ENTRY" "$@" diff --git a/.agents/skills/gstack/bin/gstack-ios-qa-regen b/.agents/skills/gstack/bin/gstack-ios-qa-regen new file mode 100755 index 0000000..ffaec9f --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-ios-qa-regen @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge +# package and the app-owned typed state accessors. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: gstack-ios-qa-regen --app-source --bridge-dir + + --app-source Swift source tree to scan for @Observable state + --bridge-dir Destination for the generated local DebugBridge package +EOF +} + +APP_SOURCE="" +BRIDGE_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --app-source) + [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; } + APP_SOURCE="$2" + shift 2 + ;; + --bridge-dir) + [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; } + BRIDGE_DIR="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "gstack-ios-qa-regen: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then + echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$APP_SOURCE" ]]; then + echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2 + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates" +GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts" +VERSION_FILE="$GSTACK_ROOT/VERSION" +GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated" + +for required in "$GENERATOR" "$VERSION_FILE"; do + if [[ ! -f "$required" ]]; then + echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2 + exit 1 + fi +done + +TMP_FILE="" +cleanup() { + if [[ -n "$TMP_FILE" ]]; then + rm -f "$TMP_FILE" + fi +} +trap cleanup EXIT + +# Copy through a sibling temporary file so interruption never leaves a +# truncated generated source. Preserve an unchanged destination byte-for-byte +# and metadata-for-metadata on repeated runs. +install_file() { + local source="$1" + local destination="$2" + + if [[ ! -f "$source" ]]; then + echo "gstack-ios-qa-regen: missing template: $source" >&2 + exit 1 + fi + if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then + return + fi + + mkdir -p "$(dirname "$destination")" + TMP_FILE="${destination}.tmp.$$" + cp "$source" "$TMP_FILE" + mv "$TMP_FILE" "$destination" + TMP_FILE="" +} + +# Invalidate the completion marker before changing any package source. A +# failed or interrupted regeneration must never look current to ios-sync. +mkdir -p "$GENERATED_DIR" +rm -f -- "$GENERATED_DIR/.gstack-version" + +# This is intentionally an allowlist, not a template glob. Wiring belongs to +# the consuming app and StateAccessor.swift is emitted by the parser below. +install_file "$TEMPLATE_DIR/Package.swift.template" \ + "$BRIDGE_DIR/Package.swift" +install_file "$TEMPLATE_DIR/StateServer.swift.template" \ + "$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift" +install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \ + "$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift" +install_file "$TEMPLATE_DIR/Bridges.swift.template" \ + "$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift" +install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \ + "$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift" +install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \ + "$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m" +install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \ + "$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h" + +# Older ios-sync versions copied the entire template set flat into the app's +# generated-source directory. Those files can shadow the package modules or +# make Xcode compile two harness implementations. Remove only the explicit +# obsolete generated paths; handwritten app sources are never touched. +for obsolete in \ + "$BRIDGE_DIR/DebugBridgeWiring.swift" \ + "$BRIDGE_DIR/StateAccessor.swift" \ + "$GENERATED_DIR/Package.swift" \ + "$GENERATED_DIR/StateServer.swift" \ + "$GENERATED_DIR/DebugBridgeManager.swift" \ + "$GENERATED_DIR/Bridges.swift" \ + "$GENERATED_DIR/DebugOverlay.swift" \ + "$GENERATED_DIR/DebugBridgeTouch.m" \ + "$GENERATED_DIR/DebugBridgeTouch.h" \ + "$GENERATED_DIR/DebugBridgeWiring.swift" +do + if [[ -f "$obsolete" || -L "$obsolete" ]]; then + rm -f -- "$obsolete" + echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete" + fi +done + +bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR" + +# Stamp only after successful accessor generation. ios-sync uses this marker +# to distinguish a complete current install from an interrupted regeneration. +install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version" + +echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR" +echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift" diff --git a/.agents/skills/gstack/bin/gstack-issue-guard b/.agents/skills/gstack/bin/gstack-issue-guard new file mode 100755 index 0000000..a9c1527 --- /dev/null +++ b/.agents/skills/gstack/bin/gstack-issue-guard @@ -0,0 +1,98 @@ +#!/usr/bin/env bun +/** + * gstack-issue-guard — fetch tracker text and emit it inside the untrusted + * trust envelope (lib/tracker-guard.ts). The ONLY sanctioned path for reading + * PR/issue body text into an agent's context — the wiring scanner + * (test/tracker-guard-wiring.test.ts) fails CI on raw reads outside it. + * + * gstack-issue-guard issue # gh issue: title + body + comments + * gstack-issue-guard pr-body # gh: current PR body + * gstack-issue-guard pr-comments # gh: current PR issue-comments + * gstack-issue-guard --stdin [--source