Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## [1.43.4.0] - 2026-05-24

## **Returning `/office-hours` users now skip the introduction-tier closing pitch on their second visit.**
## **The v1.0.0.0 writer/reader split is closed: sessions now flow through `gstack-developer-profile --log-session`, which writes to the same file the reader reads.**

The bug: `office-hours/SKILL.md.tmpl` was writing session entries to `~/.gstack/builder-profile.jsonl` (the legacy path), while the v1.0.0.0 migration had moved the read path to `~/.gstack/developer-profile.json`. The reader saw `sessions: []` every time, reported `SESSION_COUNT: 0` and `TIER: introduction`, and the welcome_back tier (designed specifically to skip the closing pitch for returning users) was unreachable. Every fresh-`$HOME` user has been stuck on this since 2026-04-18.

The fix: a new `--log-session` subcommand on `bin/gstack-developer-profile` that read-modify-writes the existing `sessions[]` array. Naming matches the `gstack-*-log` family verb. Atomic mktemp+mv write. Validates input (silent skip on invalid JSON or missing required fields, matching `gstack-timeline-log:22-26`). Aggregates `signals_accumulated`, `resources_shown`, and `topics` inline. Calls `gstack-brain-enqueue` after write, mirroring `gstack-timeline-log:40`.

A latent bug surfaced and also fixed: `do_read` now filters `mode:"resources"` entries when picking `LAST_PROJECT` / `LAST_ASSIGNMENT` / `LAST_DESIGN_TITLE` / `CROSS_PROJECT`. The Phase 6 resources auto-append runs after the real session in the same `/office-hours` invocation; without the filter, the resources entry would have clobbered real-session state for the user's next visit. This bug was masked by the broken writer (no writes were landing) and activated by this fix.

### Itemized changes

### Added
- `bin/gstack-developer-profile --log-session '<json>'` — appends a session entry to `developer-profile.json`'s `sessions[]` array, with validation and aggregate updates. Joins the `gstack-*-log` family naming convention.
- `test/static-no-legacy-writes.test.ts` — static-grep invariant walking every skill dir to prevent future writers from regressing onto the legacy file.
- `test/gstack-developer-profile.test.ts` — +8 tests covering the regression (read-write-read promotes to welcome_back), aggregation (signals, resources_shown, topics dedup), validation (silent skip on invalid input), `ts` injection/preservation, and the `do_read` mode filter.

### Fixed
- `/office-hours` returning users no longer get pitched as first-timers on every visit. The welcome_back tier is now reachable for the first time since v1.0.0.0.
- `do_read` no longer picks `LAST_PROJECT` / `LAST_ASSIGNMENT` / `LAST_DESIGN_TITLE` from a Phase 6 resources auto-append entry.
- `office-hours/SKILL.md.tmpl` writer call sites (lines 490 + 893) now use the new subcommand. Regenerated `office-hours/SKILL.md`.

### Affected users
Stranded entries in `~/.gstack/builder-profile.jsonl` from the broken-writer period are not recovered automatically by this PR, by design. On the next `/office-hours` run, the first new session counts toward the welcome_back tier; 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.

### For contributors
See `docs/designs/FIX_1671_PROFILE_MIGRATION.md` for the full design including what's intentionally NOT in this fix (no reconcile path, no schema bump, no mkdir-locks — each justified against codebase patterns). Related but separate PRs: #1671 RC2 (autoplan timeline rollup), #1671 RC3 (cross-identity scope opt-in). Unblocks issue #1139's proposed fix #3 (suppress repeated YC apply suggestion via prior-session detection).

## [1.43.3.0] - 2026-05-21

## **Headed Chromium embedded by external supervisors stops auto-shutting-down after 30 minutes of HTTP idle.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.43.3.0
1.43.4.0
76 changes: 72 additions & 4 deletions bin/gstack-developer-profile
Original file line number Diff line number Diff line change
Expand Up @@ -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}/
Expand Down Expand Up @@ -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.
# -----------------------------------------------------------------------
Expand All @@ -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);

Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions docs/designs/FIX_1671_PROFILE_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Fix #1671: `/office-hours` always reports SESSION_COUNT: 0

**Status:** SHIPPED
**Branch:** fix-1671-profile-migration
**Date:** 2026-05-23
**Issue:** https://github.com/garrytan/gstack/issues/1671
**Original PR that introduced the bug:** garrytan/gstack#1039 / commit `0a803f9` / v1.0.0.0 / 2026-04-18

## The problem

`/office-hours` reports `SESSION_COUNT: 0` and `TIER: introduction` on every invocation, even for users who have run the skill many times. The `welcome_back` tier (`bin/gstack-developer-profile:165-169`) that exists to skip the closing pitch for returning users is unreachable. Live ~5 weeks on every fresh-`$HOME` user since v1.0.0.0.

## 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`. The `ensure_profile` stub created on first read has `sessions: []`; subsequent writes go to a file the reader never re-reads. Reader and writer disagree on storage.

Full root-cause analysis (including RC2/RC3 follow-ups): https://github.com/garrytan/gstack/issues/1671

## The fix

Make the writer use the same file the reader does.

### Changes

1. **`bin/gstack-developer-profile`** — add `--log-session '<json>'` subcommand:
- Validates required fields (`date`, `mode`), silent-skip on invalid input (matches `bin/gstack-timeline-log:22-26`).
- Reads existing `developer-profile.json` via `bun -e`.
- Appends entry to `sessions[]`. Updates `signals_accumulated` (per-signal-string increment, same as `do_migrate:67-69`), unions `resources_shown` and `topics`.
- Atomic mktemp+mv write (matches existing pattern at line 54).
- Calls `gstack-brain-enqueue "developer-profile.json"` after write, mirroring `bin/gstack-timeline-log:40`.

2. **`bin/gstack-developer-profile:do_read`** — filter `mode:"resources"` entries when picking LAST_PROJECT / LAST_ASSIGNMENT / LAST_DESIGN_TITLE / CROSS_PROJECT / DESIGN_*. The Phase 6 resources auto-append happens after the real session in the same /office-hours invocation; without the filter, that resources entry clobbers real-session state for the user's next session. Latent bug that was masked by the broken writer; activated by the fix.

3. **`office-hours/SKILL.md.tmpl`** — swap writers at lines 490 and 893:
- From: `echo '{...}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl"`
- To: `~/.claude/skills/gstack/bin/gstack-developer-profile --log-session '{...}' 2>/dev/null || true`
- Run `bun run gen:skill-docs` to regenerate `office-hours/SKILL.md`.

### What's NOT in the fix (intentionally)

- **No new binary.** The owner binary for `developer-profile.json` is `gstack-developer-profile`; the writer belongs there as a subcommand. `--log-session` joins the binary's existing `--migrate` / `--derive` write-side subcommand boundary, not the `gstack-*-log` event-writer family. Verb name still matches `gstack-*-log`.
- **No mkdir-locks.** Concurrent /office-hours calls have a read-modify-write race on `developer-profile.json`. The codebase accepts the same race in `gstack-config` (r-m-w on YAML, no lock). Not introduced by this fix; out of scope.
- **No schema bump.** Schema stays at `schema_version: 1`. The fix doesn't change the schema, just makes the writer use it.
- **No auto-reconcile for affected users.** Existing users with stranded `builder-profile.jsonl` entries don't get their past history auto-merged into `developer-profile.json`. 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 affected users have only a handful of stranded sessions so the loss is mostly aesthetic. Dropped the one-release-only reconcile pathway as net noise — Garry's "right-sized diff" voice.
- **No autoplan timeline rollup (RC2).** Separate concern, separate PR.
- **No project-scope opt-in (RC3).** Separate concern, separate PR.
- **No gbrain glob change.** The office-hours manifest still globs `~/.gstack/builder-profile.jsonl` for context; once new writes stop landing there, the snapshot goes cold. Update in a follow-up if it becomes a UX issue.

### Tests (all gate-tier, free, deterministic)

1. **Regression test** in `test/gstack-developer-profile.test.ts`:
- Fresh `$HOME`.
- Run /office-hours preamble: gstack-developer-profile creates empty stub.
- Call `--log-session` with a startup-mode JSON.
- Run `--read` again. Assert `SESSION_COUNT: 1`, `TIER: welcome_back`.
- Fails on current main (subcommand doesn't exist). Passes with fix.

2. **`do_read` mode filter test:** after recording a startup session followed by a resources entry, `--read` returns LAST_PROJECT / LAST_ASSIGNMENT / LAST_DESIGN_TITLE from the real session, not from the resources entry. RESOURCES_SHOWN still aggregates correctly.

3. **Validation + aggregation tests:** `--log-session` silently skips invalid JSON / missing required fields, injects `ts` if missing, preserves user-set `ts`, correctly aggregates signals/resources/topics across multiple sessions.

4. **Static-grep invariant** in `test/static-no-legacy-writes.test.ts` (new): walks every skill dir, asserts no production code path writes to `builder-profile.jsonl` except allowlisted readers (`gstack-developer-profile`, `gstack-memory-ingest.ts`, `gstack-artifacts-init`, doc files). Prevents future writers from regressing onto the legacy file.

### Acceptance criteria

- Second `/office-hours` invocation on a fresh `$HOME` returns `TIER: welcome_back`.
- `bun test` passes on the touched files in isolation.
- `bun run gen:skill-docs` produces clean diff matching the `.tmpl` edits.

### Rollout

- One commit. PATCH version bump per CHANGELOG style guide.
- CHANGELOG entry written by `/ship`. User-facing voice: lead with what users experience now that they didn't before (welcome_back tier kicks in on second visit).

## Follow-up TODOs

- Deprecate `builder-profile.jsonl` entirely (writer + shim + memory-ingest type) after one release.
- Fix RC2 (autoplan inlines sub-skills, bypassing their timeline-log preambles).
- Add `GSTACK_PROFILE_SCOPE` opt-in for power users with multiple agent identities (RC3).
- /plan-tune doesn't currently call `--derive`, so `inferred`/`gap` can drift (pre-existing, unrelated to #1671).
- `mode:"resources"` entries inflate SESSION_COUNT under the existing tier aggregator (pre-existing, unrelated to #1671 root cause).
21 changes: 9 additions & 12 deletions office-hours/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1537,12 +1537,9 @@ Count the signals. You'll use this count in Phase 6 to determine which tier of c
### Builder Profile Append

After counting signals, append a session entry to the builder profile. This is the single
source of truth for all closing state (tier, resource dedup, journey tracking).

```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
mkdir -p "$GSTACK_STATE_ROOT"
```
source of truth for all closing state (tier, resource dedup, journey tracking). The
`gstack-developer-profile --log-session` binary handles its own directory creation
and writes via atomic mktemp+mv to `~/.gstack/developer-profile.json`.

Append one JSON line with these fields (substitute actual values from this session):
- `date`: current ISO 8601 timestamp
Expand All @@ -1556,12 +1553,12 @@ Append one JSON line with these fields (substitute actual values from this sessi
- `topics`: array of 2-3 topic keywords that describe what this session was about

```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl"
~/.claude/skills/gstack/bin/gstack-developer-profile --log-session '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' 2>/dev/null || true
```

This entry is append-only. The `resources_shown` field will be updated via a second append
after resource selection in Phase 6 Beat 3.5.
The session entry is appended to `developer-profile.json`'s `sessions[]` array. A second
session entry with `mode: "resources"` is appended via `--log-session` after resource
selection in Phase 6 Beat 3.5.

---

Expand Down Expand Up @@ -2018,8 +2015,8 @@ PAUL GRAHAM ESSAYS:
1. Log the selected resource URLs to the builder profile (single source of truth).
Append a resource-tracking entry:
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "$GSTACK_STATE_ROOT/builder-profile.jsonl"
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null || true)"
~/.claude/skills/gstack/bin/gstack-developer-profile --log-session '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' 2>/dev/null || true
```

2. Log the selection to analytics:
Expand Down
Loading