v1.44.1.0 fix wave: post-windhoek paper-cut — 9 community PRs in one bundle (#1682)

* fix(office-hours): #1671 — session writer was writing to the legacy file

User-visible symptom: returning /office-hours users get the same closing
pitch every visit, no matter how many times they've run the skill. The
welcome_back tier (which exists specifically to skip the pitch for
returning users) was unreachable. Live since 2026-04-18 / v1.0.0.0 on
every fresh-$HOME user.

Root cause: the v1.0.0.0 migration moved the read path to
~/.gstack/developer-profile.json but left the writer in
office-hours/SKILL.md.tmpl writing to the legacy
~/.gstack/builder-profile.jsonl. Reader and writer disagreed on storage,
so SESSION_COUNT never incremented and /office-hours always treated the
user as a first-timer.

Fix:
- bin/gstack-developer-profile: new --log-session subcommand that
  read-modify-writes developer-profile.json's sessions[] array (atomic
  mktemp+mv, signals/resources/topics aggregation, gbrain-enqueue mirror
  of gstack-timeline-log:40). Naming matches the gstack-*-log family verb.
- bin/gstack-developer-profile: do_read filters mode:"resources" entries
  when picking LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE so the Phase
  6 resources auto-append doesn't clobber real-session state. Latent bug
  that was masked by the broken writer; activated by the fix.
- office-hours/SKILL.md.tmpl: lines 490 + 893 swap echo >> for --log-session.
- test/gstack-developer-profile.test.ts: +8 tests covering --log-session
  contract (regression, aggregation, dedup, validation, ts handling) plus
  the mode-filter regression. All 8 fail on main, all 8 pass with this fix.
- test/static-no-legacy-writes.test.ts: new static-grep invariant walking
  every skill dir to prevent future regressions onto the legacy file.

Affected users: stranded builder-profile.jsonl entries are not recovered
automatically by this PR. On their next /office-hours run, the first new
session lands in welcome_back; past data stays in the legacy file (still
readable by other tools during deprecation). Most pre-existing users have
only a handful of stranded sessions.

See docs/designs/FIX_1671_PROFILE_MIGRATION.md for scope decisions
(RC2/RC3 follow-ups, what was intentionally left out, and why).

Issue: #1671

* test(office-hours): refine #1671 invariant regex comment for literal-path scope

Clarifies that the WRITE_PATTERN regex catches literal-path writes only;
variable-indirected writes (FILE=...; echo >> "$FILE") are not detected.
The SKILL.md.tmpl assertions in the same suite pin the exact #1671
regression class directly; this regex is a backstop, not a flow analyzer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(timeline): pass read filters as data

* feat(next-version): support monorepo VERSION paths via --version-path + .gstack/version-path

The workspace-aware ship queue hardcoded the VERSION file at the repo root.
In monorepos where versioning is subproject-scoped (one app inside a larger
repo), every PR's VERSION lookup 404s, the queue silently empties, and
parallel /ship sessions all bump from "current main + 1" — producing a
cascade of slot collisions.

Repro: tinas-second-brain repo. Root VERSION is absent; the real VERSION
lives at "Tinas Second Brain/health-tracker/VERSION". In one day, four
sequential collisions: 0.4.0.1 -> 0.5.0.0 -> 0.5.0.1 -> 0.5.0.2 -> 0.5.0.3.

Fix: add a --version-path flag and a repo-local .gstack/version-path
config file. Resolution priority: CLI flag > .gstack/version-path > "VERSION".
The resolved path threads through all four call sites — git show
origin/<base>:<path>, the GitHub Contents API, the GitLab files API, and
the local sibling-worktree scan — and shows up in the JSON output as
version_path so /ship and operators can see what got picked.

The previous warning "could not fetch VERSION (fork or private)" was
misleading whenever the real cause was wrong path. The new wording names
the path that 404'd and hints at the two knobs.

Backward-compatible: no flag, no config, no change in behavior.

Tests: 6 unit tests for resolveVersionPath (priority, parsing, blank /
missing / empty edge cases) + a second integration smoke that drives
--version-path end-to-end and asserts it surfaces in JSON output.

* fix(investigate): support standalone freeze hook path

* fix(browse): clarify localhost bind failures

* fix(migration): defer v1.40.0.0 done-marker until every repair succeeds (#1581)

The v1.40.0.0 migration unconditionally `touch`ed its done-marker, even
when the jq-gated `.brain-privacy-map.json` patch was skipped because jq
was missing on the user's machine. On subsequent runs, the script
short-circuited on the marker so the privacy-map repair never landed.
Federation sync then silently dropped `/plan-eng-review` test plans.

Track every failure mode via a single `incomplete` flag: jq missing,
malformed JSON, jq mutation failure, tempfile creation failure, `mv`
failure, allowlist append failure, gitattributes append failure. The
marker is written only when `incomplete=0`, so the migration runner
retries on the next /gstack-upgrade once the prerequisites are met.

* test(migration): unit tests for v1.40.0.0 deferred done-marker fix (#1581)

8 cases pinning the fix:

- Case 1 (happy path): jq present, fresh privacy-map → all three files
  patched, marker written.
- Case 2 (regression for #1581): jq missing, privacy-map present →
  marker must NOT be written. Fails against the buggy script, passes
  against the fix.
- Case 3 (recovery): jq missing, then jq restored → patch lands on
  second run.
- Case 4 (idempotency): privacy-map already has correct entry →
  no mutation, marker written.
- Case 5 (fresh-init): privacy-map file absent → allowlist + gitattrs
  patched, marker written.
- Case 6 (malformed JSON): broken privacy-map JSON → no marker, no
  mutation.
- Case 7 (jq mutation failure): fake jq returning 1 → no marker,
  tempfile cleaned up.
- Case 8 (allowlist append failure): read-only allowlist → no marker.

Tests use spawnSync('bash', [MIGRATION], …) with isolated tmpHomes.
"jq missing" sets PATH to a curated dir of symlinks to standard utils,
omitting jq; "jq mutation fails" uses an `exit 1` shim. Avoids
blanket-clearing PATH (which would hide bash/grep/etc).

* fix(brain-sync): make artifact sync work on Windows (discover-new + drain)

Automatic artifact sync was fully non-functional on Windows (Git Bash):
--discover-new enqueued nothing and the --once drain staged nothing, so
artifacts_sync_mode looked active but no artifacts ever reached the repo.
Three independent Windows-only causes in bin/gstack-brain-sync:

1. discover-new matched os.path.relpath (backslash separators on Windows)
   against the forward-slash allowlist globs, so no nested file ever matched.
   Normalized the relpath to "/".
2. discover-new enqueued via subprocess.run([gstack-brain-enqueue, rel]), but
   Windows Python cannot exec a bash-shebang script, so nothing was enqueued
   even once matched. Now appends to the queue in-process.
3. compute_paths_to_stage ends in print(p); Windows Python emits CRLF, the
   bash `read -r` keeps the trailing CR, and `git add -- "path<CR>"` matches
   nothing under `2>/dev/null || true`. Now strips the CR before staging.

The in-process enqueue mirrors gstack-brain-enqueue's contract: one atomic
O_APPEND write per record (each line < PIPE_BUF) so a parallel writer-shim
append can't interleave mid-record, and the discover cursor advances only
after the write succeeds, so a failed write retries instead of silently
recording the file as synced. Skip-list entries are separator-normalized on
both the discover and drain (compute_paths_to_stage) sides, so a backslash
.brain-skip.txt entry can't be honored at discovery yet bypassed at commit.

Adds test/brain-sync-windows-paths.test.ts (static invariants -- behavioral
spawn tests cannot run on the Windows lane, since Node/Bun cannot exec the
bin/ shebang scripts there) and wires it into windows-free-tests.yml.
Verified red->green and end-to-end on Windows 11 / Git Bash; macOS/Linux
behavior unchanged (os.sep is already "/", no CRLF, compute path logic
unchanged besides the shared skip normalization).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: detect bun.lock (Bun v1.2+ text lockfile) in diff-scope CONFIG

gstack-diff-scope only matched the legacy binary lockfile `bun.lockb`
but not the newer text-based `bun.lock` introduced in Bun v1.2+.
Projects using current Bun versions were silently missing the
SCOPE_CONFIG signal when only the lockfile changed.

🤖 Generated with [Qoder][https://qoder.com]

* fix(ios-qa): resolve CoreDevice tunnel via devicectl + keep tunnel alive

The daemon's tunnel bootstrap used `dns.resolve6` to look up
`<device>.coredevice.local`, which fails with ESERVFAIL on macOS 26.x
(Darwin 25.x) because Node's resolve6 path goes through libresolv and
does NOT consult mDNSResponder. `dns.lookup` (getaddrinfo) does.

Even when resolution works, CoreDevice in Xcode 26 only holds the
USB tunnel up while a devicectl command is in-flight, so the IPv6 ULA
becomes unroutable within ~10-15s of idle and subsequent proxy
requests time out.

Two-part fix:

  1. Resolution order is now (a) `xcrun devicectl device info details
     --json-output` to read `result.connectionProperties.tunnelIPAddress`
     directly, (b) mDNS via `dns.lookup`, (c) legacy `dns.resolve6` as
     a last-ditch fallback.
  2. After a successful bootstrap the daemon spawns a periodic
     `devicectl device info details` (~5s) to keep the tunnel session
     alive. Cleaned up on SIGINT/SIGTERM/exit.

Adds tests for `getDeviceTunnelIPv6FromDevicectl`, the
`resolveTunnelIPv6` fallback chain, and `startTunnelKeepalive`.
Existing bootstrap tests updated to include the new
`device info details` spawn step.

Tested against: iPhone 12 Pro on iOS 26.x via Mac Mini M-series
running macOS Sequoia 15.x / Darwin 25.3.0.

* chore(release): v1.44.1.0 — 9-PR community fix wave (post-windhoek paper-cut)

Bump VERSION + CHANGELOG entry. Wave covers /office-hours session
counter, iOS QA macOS 26 tunnels, Windows brain-sync, browse server
bind diagnostics, monorepo VERSION layouts, /investigate freeze hook
on standalone installs, gstack-timeline-read quote injection,
v1.40.0.0 migration on jq-less machines, bun.lock detection.

9 community PRs: #1676 #1635 #1627 #1648 #1664 #1589 #1672 #1649 #1673
9 contributors credited: @pryow @jbetala7 @cfeddersen @Gujiassh
@spacegeologist @stedfn @daveowenatl @hiSandog @sternryan
4 issues closed: #1671 #1677 #1634 #1647 #1581

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Rook <rook@robomovers.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: Christoph <astaran@herr-der-ringe-film.de>
Co-authored-by: gujishh <baiaoshh@163.com>
Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com>
Co-authored-by: Stefan Neamtu <stefan.neamtu@nearone.org>
Co-authored-by: Dave Owen <daveowen66@gmail.com>
Co-authored-by: 陈家名 <chenjiaming@kezaihui.com>
Co-authored-by: Ryan Stern <206953196+sternryan@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-05-25 10:57:15 -07:00
committed by GitHub
parent 920a13a17f
commit 64f9aafa1e
29 changed files with 1696 additions and 116 deletions
+57 -12
View File
@@ -136,7 +136,11 @@ def load_privacy_map(path):
allowlist_globs = load_lines(allowlist_path)
privacy_map = load_privacy_map(privacy_path)
skip_lines = set(load_lines(skip_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)}
# Read queue; collect unique file paths.
queue_paths = set()
@@ -253,6 +257,8 @@ subcmd_once() {
# 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"
@@ -376,10 +382,13 @@ subcmd_discover_new() {
exit 0
fi
# Walk allowlist globs; enqueue any file where mtime+size differs from cursor.
python3 - "$GSTACK_HOME" "$ALLOWLIST" "$DISCOVER_CURSOR" "$SCRIPT_DIR/gstack-brain-enqueue" <<'PYEOF' 2>/dev/null || true
import sys, os, json, glob, fnmatch, subprocess, hashlib
python3 - "$GSTACK_HOME" "$ALLOWLIST" "$DISCOVER_CURSOR" <<'PYEOF' 2>/dev/null || true
import sys, os, json, fnmatch
from datetime import datetime, timezone
gstack_home, allowlist_path, cursor_path, enqueue_bin = sys.argv[1:5]
gstack_home, allowlist_path, cursor_path = sys.argv[1:4]
queue_path = os.path.join(gstack_home, ".brain-queue.jsonl")
skip_path = os.path.join(gstack_home, ".brain-skip.txt")
def load_lines(path):
try:
@@ -403,8 +412,12 @@ def save_cursor(path, data):
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):
@@ -413,22 +426,54 @@ for root, dirs, files in os.walk(gstack_home):
continue
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, gstack_home)
# 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
matched = any(fnmatch.fnmatchcase(rel, pat) for pat in allowlist)
if not matched:
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
prev = cursor.get(rel)
if prev != key:
# Enqueue via the shim (respects sync mode + skip list).
subprocess.run([enqueue_bin, rel], check=False)
new_cursor[rel] = key
if cursor.get(rel) != key:
to_enqueue.append((rel, key))
# Append to the queue 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 queue line 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")
try:
# One atomic append per record (O_APPEND, each line < PIPE_BUF), matching
# gstack-brain-enqueue's concurrency contract so a writer-shim append
# running in parallel can't interleave mid-record. Buffered text writes
# don't guarantee that. Compact separators match the shim's JSON shape.
fd = os.open(queue_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
try:
for rel, key in to_enqueue:
rec = json.dumps({"file": rel, "ts": ts}, separators=(",", ":"))
os.write(fd, (rec + "\n").encode("utf-8"))
finally:
os.close(fd)
except OSError:
# Queue write failed (disk full, AV file lock). Leave the cursor
# unadvanced so these files are retried on the next discover instead of
# being silently recorded as synced (which loses the change until the
# file next changes).
to_enqueue = []
# Advance the cursor only for records actually written.
for rel, key in to_enqueue:
new_cursor[rel] = key
save_cursor(cursor_path, new_cursor)
PYEOF
+72 -4
View File
@@ -17,6 +17,9 @@
# --check-mismatch detect meaningful gaps between declared and observed.
# --migrate migrate builder-profile.jsonl → developer-profile.json.
# Idempotent; archives the source file on success.
# --log-session append a session entry (from /office-hours) to
# sessions[] and update aggregates. Required fields:
# date, mode. Silent skip on invalid input.
#
# Profile file: ~/.gstack/developer-profile.json (unified schema — see
# docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/
@@ -154,6 +157,65 @@ ensure_profile() {
EOF
}
# -----------------------------------------------------------------------
# Record session: append a session entry from /office-hours to sessions[]
# and update aggregates (signals_accumulated, resources_shown, topics).
# Fix for #1671: the writer side of the v1.0.0.0 migration. Reader and
# writer now share the same file.
# Silent skip on invalid input (matches gstack-timeline-log:22-26 pattern).
# -----------------------------------------------------------------------
do_log_session() {
local INPUT="${1:-}"
if [ -z "$INPUT" ]; then
return 0
fi
# Validate: input must be parseable JSON with required fields (date, mode).
if ! printf '%s' "$INPUT" | bun -e "
const j = JSON.parse(await Bun.stdin.text());
if (!j.date || !j.mode) process.exit(1);
" 2>/dev/null; then
return 0
fi
ensure_profile
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.XXXXXX.tmp")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun -e "
const fs = require('fs');
const entry = JSON.parse(process.env.RECORD_INPUT);
if (!entry.ts) entry.ts = new Date().toISOString();
const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'));
profile.sessions = profile.sessions || [];
profile.sessions.push(entry);
profile.signals_accumulated = profile.signals_accumulated || {};
for (const s of (entry.signals || [])) {
profile.signals_accumulated[s] = (profile.signals_accumulated[s] || 0) + 1;
}
profile.resources_shown = profile.resources_shown || [];
const resSet = new Set(profile.resources_shown);
for (const r of (entry.resources_shown || [])) resSet.add(r);
profile.resources_shown = Array.from(resSet);
profile.topics = profile.topics || [];
const topicSet = new Set(profile.topics);
for (const t of (entry.topics || [])) topicSet.add(t);
profile.topics = Array.from(topicSet);
fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2));
"
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
"$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
}
# -----------------------------------------------------------------------
# Read: emit legacy KEY: VALUE output for /office-hours compat.
# -----------------------------------------------------------------------
@@ -168,14 +230,19 @@ do_read() {
else if (count >= 4) tier = 'regular';
else if (count >= 1) tier = 'welcome_back';
const last = sessions[count - 1] || {};
const prev = sessions[count - 2] || {};
// LAST_* / CROSS_PROJECT must reflect real sessions, not resource-tracking
// events (the Phase 6 auto-append). Without this filter, a session's
// resources entry written immediately after the real session would clobber
// LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE.
const realSessions = sessions.filter(e => e.mode !== 'resources');
const last = realSessions[realSessions.length - 1] || {};
const prev = realSessions[realSessions.length - 2] || {};
const crossProject = prev.project_slug && last.project_slug
? prev.project_slug !== last.project_slug
: false;
const designs = sessions.map(e => e.design_doc || '').filter(Boolean);
const designTitles = sessions
const designs = realSessions.map(e => e.design_doc || '').filter(Boolean);
const designTitles = realSessions
.map(e => (e.design_doc ? (e.project_slug || 'unknown') : ''))
.filter(Boolean);
@@ -441,6 +508,7 @@ case "$CMD" in
--vibe) do_vibe ;;
--check-mismatch) do_check_mismatch ;;
--migrate) do_migrate ;;
--log-session) do_log_session "$@" ;;
--help|-h) sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||' ;;
*)
echo "gstack-developer-profile: unknown subcommand '$CMD'" >&2
+1 -1
View File
@@ -57,7 +57,7 @@ while IFS= read -r f; do
*.md) DOCS=true ;;
# Config
package.json|package-lock.json|yarn.lock|bun.lockb) CONFIG=true ;;
package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) CONFIG=true ;;
Gemfile|Gemfile.lock) CONFIG=true ;;
*.yml|*.yaml) CONFIG=true ;;
.github/*) CONFIG=true ;;
+61 -20
View File
@@ -10,7 +10,14 @@
//
// Usage:
// gstack-next-version --base <branch> --bump <major|minor|patch|micro> \
// --current-version <X.Y.Z.W> [--workspace-root <path>|null] [--json]
// --current-version <X.Y.Z.W> [--workspace-root <path>|null] \
// [--version-path <path>] [--json]
//
// VERSION path resolution (monorepo support):
// 1. --version-path <path> CLI flag (highest priority)
// 2. .gstack/version-path file at the repo root (single-line relative path,
// committed so all collaborators benefit)
// 3. "VERSION" at the repo root (default, backward-compatible)
//
// Exit codes:
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown")
@@ -45,6 +52,7 @@ type Output = {
version: string;
current_version: string;
base_version: string;
version_path: string;
bump: Bump;
host: "github" | "gitlab" | "unknown";
offline: boolean;
@@ -114,6 +122,28 @@ function runCommand(cmd: string, args: string[], timeoutMs = 15000): { ok: boole
};
}
// VERSION-path resolution for monorepos. Priority: CLI flag > .gstack/version-path
// at repo root > "VERSION". Pure function; takes the repo root as an argument so
// tests can drive it with a fixture dir without mocking git.
function resolveVersionPath(override: string | undefined, repoRoot: string): string {
if (override) return override.trim();
const configFile = join(repoRoot, ".gstack", "version-path");
if (existsSync(configFile)) {
try {
const firstLine = readFileSync(configFile, "utf8").split("\n")[0]?.trim() ?? "";
if (firstLine) return firstLine;
} catch {
// fall through to default
}
}
return "VERSION";
}
function repoToplevel(): string {
const r = runCommand("git", ["rev-parse", "--show-toplevel"]);
return r.ok ? r.stdout.trim() : process.cwd();
}
function detectHost(): "github" | "gitlab" | "unknown" {
const remote = runCommand("git", ["remote", "get-url", "origin"]);
if (remote.ok) {
@@ -128,19 +158,19 @@ function detectHost(): "github" | "gitlab" | "unknown" {
return "unknown";
}
function readBaseVersion(base: string, warnings: string[]): string {
function readBaseVersion(base: string, versionPath: string, warnings: string[]): string {
// git fetch is best-effort; we tolerate failure and fall back to whatever
// origin/<base> currently points at.
runCommand("git", ["fetch", "origin", base, "--quiet"], 10000);
const r = runCommand("git", ["show", `origin/${base}:VERSION`]);
const r = runCommand("git", ["show", `origin/${base}:${versionPath}`]);
if (!r.ok) {
warnings.push(`could not read VERSION at origin/${base}; assuming 0.0.0.0`);
warnings.push(`could not read ${versionPath} at origin/${base}; assuming 0.0.0.0`);
return "0.0.0.0";
}
return r.stdout.trim();
}
async function fetchGithubClaimed(base: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
async function fetchGithubClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
const list = runCommand("gh", [
"pr",
"list",
@@ -187,14 +217,18 @@ async function fetchGithubClaimed(base: string, excludePR: number | null, warnin
const pr = queue.shift();
if (!pr) return;
// gh passes branch name via argv, not shell — safe.
// encodeURI handles spaces in subproject paths (e.g. "Tinas Second Brain/...")
// while leaving "/" untouched so the GitHub Contents API gets the path intact.
const content = runCommand("gh", [
"api",
`repos/{owner}/{repo}/contents/VERSION?ref=${encodeURIComponent(pr.headRefName)}`,
`repos/{owner}/{repo}/contents/${encodeURI(versionPath)}?ref=${encodeURIComponent(pr.headRefName)}`,
"-q",
".content",
]);
if (!content.ok) {
warnings.push(`PR #${pr.number}: could not fetch VERSION (fork or private)`);
warnings.push(
`PR #${pr.number}: could not fetch ${versionPath} (fork, private, or wrong path — try --version-path or .gstack/version-path)`,
);
continue;
}
let versionStr: string;
@@ -215,7 +249,7 @@ async function fetchGithubClaimed(base: string, excludePR: number | null, warnin
return { claimed: results, offline: false };
}
async function fetchGitlabClaimed(base: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
async function fetchGitlabClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
const list = runCommand("glab", [
"mr",
"list",
@@ -243,12 +277,15 @@ async function fetchGitlabClaimed(base: string, excludePR: number | null, warnin
}
const results: ClaimedPR[] = [];
for (const mr of mrs) {
// GitLab files API takes the full path URL-encoded (slashes become %2F).
const content = runCommand("glab", [
"api",
`projects/:id/repository/files/VERSION?ref=${encodeURIComponent(mr.source_branch)}`,
`projects/:id/repository/files/${encodeURIComponent(versionPath)}?ref=${encodeURIComponent(mr.source_branch)}`,
]);
if (!content.ok) {
warnings.push(`MR !${mr.iid}: could not fetch VERSION`);
warnings.push(
`MR !${mr.iid}: could not fetch ${versionPath} (wrong path? — try --version-path or .gstack/version-path)`,
);
continue;
}
try {
@@ -285,7 +322,7 @@ function currentRepoSlug(): string {
return m ? m[1] : "";
}
function scanSiblings(root: string | null, claimed: ClaimedPR[], warnings: string[]): Sibling[] {
function scanSiblings(root: string | null, versionPath: string, claimed: ClaimedPR[], warnings: string[]): Sibling[] {
if (!root || !existsSync(root)) return [];
const mySlug = currentRepoSlug();
if (!mySlug) {
@@ -308,7 +345,7 @@ function scanSiblings(root: string | null, claimed: ClaimedPR[], warnings: strin
continue;
}
if (!existsSync(join(p, ".git")) && !existsSync(join(p, ".git/HEAD"))) continue;
const versionFile = join(p, "VERSION");
const versionFile = join(p, versionPath);
if (!existsSync(versionFile)) continue;
let version: string;
try {
@@ -346,12 +383,13 @@ function markActiveSiblings(siblings: Sibling[], baseVersion: Version): Sibling[
});
}
function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; workspaceRoot?: string; excludePR: number | null; help: boolean } {
function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; workspaceRoot?: string; excludePR: number | null; versionPath?: string; help: boolean } {
let base = "";
let bump: Bump | "" = "";
let current = "";
let workspaceRoot: string | undefined;
let excludePR: number | null = null;
let versionPath: string | undefined;
let help = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
@@ -359,6 +397,7 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
else if (a === "--bump") bump = (argv[++i] ?? "") as Bump;
else if (a === "--current-version") current = argv[++i] ?? "";
else if (a === "--workspace-root") workspaceRoot = argv[++i];
else if (a === "--version-path") versionPath = argv[++i];
else if (a === "--exclude-pr") {
const n = Number(argv[++i]);
excludePR = Number.isFinite(n) && n > 0 ? n : null;
@@ -375,7 +414,7 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
console.error(`Error: --bump must be major|minor|patch|micro (got ${bump})`);
process.exit(2);
}
return { base, bump: bump as Bump, current, workspaceRoot, excludePR, help: false };
return { base, bump: bump as Bump, current, workspaceRoot, excludePR, versionPath, help: false };
}
// Auto-detect: if --exclude-pr wasn't passed, check whether the current branch
@@ -392,13 +431,14 @@ async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(
"Usage: gstack-next-version --base <branch> --bump <level> --current-version <X.Y.Z.W> [--workspace-root <path|null>]",
"Usage: gstack-next-version --base <branch> --bump <level> --current-version <X.Y.Z.W> [--workspace-root <path|null>] [--version-path <path>]",
);
process.exit(0);
}
const warnings: string[] = [];
const host = detectHost();
const baseVersion = args.current || readBaseVersion(args.base, warnings);
const versionPath = resolveVersionPath(args.versionPath, repoToplevel());
const baseVersion = args.current || readBaseVersion(args.base, versionPath, warnings);
const baseParsed = parseVersion(baseVersion);
if (!baseParsed) {
console.error(`Error: could not parse base version '${baseVersion}'`);
@@ -413,9 +453,9 @@ async function main() {
let claimed: ClaimedPR[] = [];
let offline = false;
if (host === "github") {
({ claimed, offline } = await fetchGithubClaimed(args.base, excludePR, warnings));
({ claimed, offline } = await fetchGithubClaimed(args.base, versionPath, excludePR, warnings));
} else if (host === "gitlab") {
({ claimed, offline } = await fetchGitlabClaimed(args.base, excludePR, warnings));
({ claimed, offline } = await fetchGitlabClaimed(args.base, versionPath, excludePR, warnings));
} else {
warnings.push("host unknown; queue-awareness unavailable");
}
@@ -433,7 +473,7 @@ async function main() {
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump);
const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot);
const siblings = markActiveSiblings(scanSiblings(workspaceRoot, claimed, warnings), baseParsed);
const siblings = markActiveSiblings(scanSiblings(workspaceRoot, versionPath, claimed, warnings), baseParsed);
const activeSiblings = siblings.filter((s) => s.is_active);
// If an active sibling outranks our pick, bump past it (same bump level).
@@ -453,6 +493,7 @@ async function main() {
version: fmtVersion(finalVersion),
current_version: args.current || baseVersion,
base_version: baseVersion,
version_path: versionPath,
bump: args.bump,
host,
offline,
@@ -466,7 +507,7 @@ async function main() {
}
// Pure-function exports for testing
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings };
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings, resolveVersionPath };
// Only run main() when invoked as a script, not when imported by tests.
if (import.meta.main) {
+6 -4
View File
@@ -29,11 +29,13 @@ if [ ! -f "$TIMELINE_FILE" ]; then
exit 0
fi
cat "$TIMELINE_FILE" 2>/dev/null | bun -e "
cat "$TIMELINE_FILE" 2>/dev/null | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
const since = '${SINCE}';
const branch = '${BRANCH}';
const limit = ${LIMIT};
const since = process.env.GSTACK_TIMELINE_SINCE || '';
const branch = process.env.GSTACK_TIMELINE_BRANCH || '';
const limitRaw = process.env.GSTACK_TIMELINE_LIMIT || '20';
const parsedLimit = Number.parseInt(limitRaw, 10);
const limit = Number.isSafeInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20;
let sinceMs = 0;
if (since) {