mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-19 16:20:09 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/trunk-land-skill
# Conflicts: # CHANGELOG.md # VERSION # package.json
This commit is contained in:
+45
-1
@@ -72,7 +72,48 @@ fi
|
||||
# no-op skip (no install, no decline marker). A dev workspace must never mutate
|
||||
# global settings.json. To install the hooks, run `./setup --plan-tune-hooks`
|
||||
# directly (outside dev-setup). Saved prefix/other config preferences still apply.
|
||||
"$GSTACK_LINK/setup" --plan-tune-hooks=prompt </dev/null
|
||||
#
|
||||
# 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
|
||||
|
||||
# 7. Brain-aware (gbrain) blocks — render into an untracked workspace dir.
|
||||
#
|
||||
# The worktree's SKILL.md files stay canonical (the guard above). If gbrain is
|
||||
# installed, render the :user variant (with GBRAIN_CONTEXT_LOAD +
|
||||
# GBRAIN_SAVE_RESULTS) into .claude/gstack-rendered (gitignored, per-workspace)
|
||||
# and repoint the workspace's SKILL.md symlinks at it. gen-skill-docs --out-dir
|
||||
# also rewrites the section-base path so section reads resolve to the render, not
|
||||
# the global install. Result: this workspace gets the full gbrain experience
|
||||
# while git stays clean. Other projects pick up blocks via `gstack-config
|
||||
# gbrain-refresh` (printed below).
|
||||
GBRAIN_DETECT="$REPO_ROOT/bin/gstack-gbrain-detect"
|
||||
RENDER_DIR="$REPO_ROOT/.claude/gstack-rendered"
|
||||
if [ -x "$GBRAIN_DETECT" ] && "$GBRAIN_DETECT" --is-ok 2>/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/<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."
|
||||
@@ -80,4 +121,7 @@ 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"
|
||||
|
||||
+8
-1
@@ -24,9 +24,16 @@ if [ -d "$CLAUDE_SKILLS" ]; then
|
||||
fi
|
||||
|
||||
rmdir "$CLAUDE_SKILLS" 2>/dev/null || true
|
||||
rmdir "$REPO_ROOT/.claude" 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
|
||||
|
||||
+40
-3
@@ -86,7 +86,16 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# # --no-plan-tune-hooks, or env GSTACK_PLAN_TUNE_HOOKS.
|
||||
#
|
||||
# ─── Advanced ────────────────────────────────────────────────────────
|
||||
# codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship
|
||||
# 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)
|
||||
#
|
||||
@@ -302,6 +311,13 @@ case "${1:-}" in
|
||||
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
|
||||
@@ -396,8 +412,29 @@ case "${1:-}" in
|
||||
|
||||
case "$STATUS" in
|
||||
ok)
|
||||
echo "Detected gbrain v$VERSION → brain-aware blocks will render in planning-skill SKILL.md files."
|
||||
echo "Run 'bun run gen:skill-docs' in the gstack repo (or re-run ./setup) to regenerate now."
|
||||
echo "Detected gbrain v$VERSION."
|
||||
# Render brain-aware blocks INTO the global install so EVERY project's
|
||||
# Claude sessions get them (other projects read SKILL.md + sections from
|
||||
# ~/.claude/skills/gstack via absolute paths baked at gen time). Guards
|
||||
# (never mutate an arbitrary directory): the target must exist, not be a
|
||||
# symlink (a symlinked install points at a dev worktree — rendering there
|
||||
# would dirty tracked source), and look like a real gstack clone.
|
||||
INSTALL_DIR="$HOME/.claude/skills/gstack"
|
||||
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). Rendering there would dirty tracked source — 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'."
|
||||
elif ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude >/dev/null 2>&1 ); then
|
||||
echo "Rendered brain-aware blocks into $INSTALL_DIR — now live across all your projects' Claude sessions."
|
||||
echo "Note: this dirties the install's git tree (generated blocks differ from main, by design)."
|
||||
echo " A 'git reset --hard origin/main' there reverts them; re-run 'gstack-config gbrain-refresh' to restore."
|
||||
else
|
||||
echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude' manually to see the error."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files."
|
||||
|
||||
Executable
+167
@@ -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/<label>-<slug>-<branch>-<ts>-<pid>.log), so
|
||||
concurrent runs never clobber or contaminate each other's logs.
|
||||
* No silent hang: `--timeout SECS` watchdog kills a stalled run, and a
|
||||
`### gstack-detach EXIT=<code> ###` sentinel is ALWAYS appended on a
|
||||
terminal path so a poller can tell finished-vs-died (silence != success).
|
||||
|
||||
Usage:
|
||||
gstack-detach [--log PATH] [--lock NAME] [--timeout SECS] [--label LBL] -- CMD [ARGS...]
|
||||
|
||||
Prints `gstack-detach LOG <path>` and returns immediately. Poll the log; break
|
||||
on `### gstack-detach EXIT=` (both success and failure are marked).
|
||||
|
||||
Secrets are inherited from the environment ONLY — never pass an API key in argv.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _git(*args):
|
||||
try:
|
||||
return subprocess.check_output(["git", *args], stderr=subprocess.DEVNULL, text=True).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def run_scoped_log(label):
|
||||
base = os.path.expanduser("~/.gstack-dev/eval-runs")
|
||||
os.makedirs(base, exist_ok=True)
|
||||
root = _git("rev-parse", "--show-toplevel")
|
||||
slug = os.path.basename(root) if root else "unknown"
|
||||
branch = (_git("branch", "--show-current") or "nobranch").replace("/", "-")
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
return os.path.join(base, f"{label}-{slug}-{branch}-{stamp}-{os.getpid()}.log")
|
||||
|
||||
|
||||
def log_line(path, msg):
|
||||
with open(path, "ab", buffering=0) as f:
|
||||
f.write((msg + "\n").encode("utf-8", "replace"))
|
||||
|
||||
|
||||
def acquire_lock(name, log):
|
||||
"""Machine-wide advisory lock via fcntl (portable on macOS + Linux). Blocks
|
||||
until free so concurrent worktrees serialize rather than saturate the API.
|
||||
Returns the held fd (kept open for the process lifetime)."""
|
||||
import fcntl
|
||||
|
||||
d = os.path.expanduser("~/.gstack/locks")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
fd = open(os.path.join(d, f"{name}.lock"), "w")
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log_line(log, f"### gstack-detach WAITING for lock '{name}' (another run holds it) ### {_now()}")
|
||||
fcntl.flock(fd, fcntl.LOCK_EX) # block until released
|
||||
fd.write(f"{os.getpid()} {_now()}\n")
|
||||
fd.flush()
|
||||
log_line(log, f"### gstack-detach LOCK '{name}' ACQUIRED ### {_now()}")
|
||||
return fd
|
||||
|
||||
|
||||
def child_run(args, log):
|
||||
lock_fd = acquire_lock(args.lock, log) if args.lock else None
|
||||
cmd = args.cmd
|
||||
if shutil.which("caffeinate"): # macOS: block idle-sleep for the run
|
||||
cmd = ["caffeinate", "-i", *cmd]
|
||||
log_line(log, f"### gstack-detach START label={args.label} pgid={os.getpgid(0)} ### {_now()}")
|
||||
with open(log, "ab", buffering=0) as f:
|
||||
# start_new_session: the command runs in its OWN process group so the
|
||||
# watchdog can killpg() it without also killing this supervisor (which
|
||||
# must survive to write the EXIT sentinel).
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=f, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True
|
||||
)
|
||||
if args.timeout and args.timeout > 0:
|
||||
try:
|
||||
code = proc.wait(timeout=args.timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
log_line(log, f"### gstack-detach WATCHDOG fired after {args.timeout}s — killing ### {_now()}")
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
code = "timeout"
|
||||
else:
|
||||
code = proc.wait()
|
||||
log_line(log, f"### gstack-detach EXIT={code} ### {_now()}")
|
||||
if lock_fd:
|
||||
try:
|
||||
lock_fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(add_help=True)
|
||||
ap.add_argument("--log")
|
||||
ap.add_argument("--lock")
|
||||
ap.add_argument("--timeout", type=int, default=0)
|
||||
ap.add_argument("--label", default="job")
|
||||
ap.add_argument("cmd", nargs=argparse.REMAINDER)
|
||||
args = ap.parse_args()
|
||||
|
||||
cmd = args.cmd
|
||||
if cmd and cmd[0] == "--":
|
||||
cmd = cmd[1:]
|
||||
if not cmd:
|
||||
print("gstack-detach: no command given (usage: gstack-detach [opts] -- CMD...)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
args.cmd = cmd
|
||||
|
||||
log = args.log or run_scoped_log(args.label)
|
||||
os.makedirs(os.path.dirname(log) or ".", exist_ok=True)
|
||||
open(log, "ab").close()
|
||||
|
||||
# Detach: fork so the launching shell returns immediately, then setsid in the
|
||||
# child to escape the harness's process group / controlling terminal.
|
||||
if os.fork() > 0:
|
||||
# flush BEFORE os._exit — os._exit skips stdio buffer flush, which would
|
||||
# otherwise drop this line and leave the caller without the log path.
|
||||
print(f"gstack-detach LOG {log}", flush=True)
|
||||
os._exit(0)
|
||||
os.setsid()
|
||||
devnull = os.open(os.devnull, os.O_RDWR)
|
||||
os.dup2(devnull, 0)
|
||||
lf = os.open(log, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
|
||||
os.dup2(lf, 1)
|
||||
os.dup2(lf, 2)
|
||||
try:
|
||||
child_run(args, log)
|
||||
except Exception as e: # never leave the log without a terminal marker
|
||||
log_line(log, f"### gstack-detach ERROR {e!r} ### {_now()}")
|
||||
log_line(log, f"### gstack-detach EXIT=error ### {_now()}")
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -234,4 +234,14 @@ function main(): void {
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok"), 1
|
||||
// otherwise. Runs detection live (never reads the possibly-stale
|
||||
// gbrain-detection.json), so callers — setup, bin/dev-setup, and
|
||||
// `gstack-config gbrain-refresh` — can decide whether to render the gbrain
|
||||
// :user variant without duplicating the JSON grep. Prints nothing on stdout.
|
||||
if (process.argv.includes("--is-ok")) {
|
||||
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
|
||||
process.exit(localEngineStatus({ noCache }) === "ok" ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
Reference in New Issue
Block a user