Skip to content
Merged
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
11 changes: 7 additions & 4 deletions .agents/skills/ship-pseudocode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ Homebrew, export the branding variables the desktop build needs, bump the iOS bu
the signing team to every target, and verify the
result before claiming success.

`ship-mac.sh` ends with `spctl` reporting `source=Notarized Developer ID`, then publishes the DMG to
GitHub Releases under the tag `pseudocode-v<version>`, taking the version from
`ship-mac.sh` builds only from `origin/main` with a clean tree: release changes reach main through
a merged PR first, and the release is cut from that merged commit. It ends with `spctl` reporting
`source=Notarized Developer ID`, then uploads the DMG to a **draft** GitHub release under the tag
`pseudocode-v<version>`, pinned to the built commit, taking the version from
`apps/server/package.json`, which is the one electron-builder stamps onto the artifact. Anything
less than a notarized result is a failure, not a warning, and
publishing only happens after verification passes: an unnotarized DMG on a release page is worse
than no DMG, because macOS refuses to open it.
uploading only happens after verification passes: an unnotarized DMG on a release page is worse
than no DMG, because macOS refuses to open it. Going live is a separate step, once Chris approves
that release: `gh release edit pseudocode-v<version> --repo pseudoseed/t3code_dev --draft=false --latest`.

The tag is deliberately not `v<version>`. The release workflow triggers on `v*.*.*` and cannot
produce a usable artifact here, so a tag in that shape starts a run guaranteed to fail. Re-running
Expand Down
15 changes: 15 additions & 0 deletions .agents/skills/ship-pseudocode/scripts/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ load_env_local() {
set +a
}

# A release is built from fork main as merged on GitHub, so its tag can point at the exact
# commit that produced the artifact. Anything else is a build of code main does not have.
require_main_head() {
git fetch --quiet origin main
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "HEAD is not origin/main. Merge the release PR, then build from origin/main:" >&2
echo " git switch --detach origin/main" >&2
exit 1
fi
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "Uncommitted changes. A release builds exactly what origin/main holds." >&2
exit 1
fi
}

require_vars() {
local missing=()
for name in "$@"; do
Expand Down
11 changes: 9 additions & 2 deletions .agents/skills/ship-pseudocode/scripts/ship-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

ROOT="$(repo_root)"
cd "$ROOT"
require_main_head
COMMIT="$(git rev-parse HEAD)"
setup_path "$ROOT"
load_env_local "$ROOT"
require_vars T3CODE_APP_NAME T3CODE_APP_ICON T3CODE_DESKTOP_UPDATE_REPOSITORY
Expand Down Expand Up @@ -91,13 +93,18 @@ if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
echo "Adding $VERSION $ARCH artifacts to $TAG"
gh release upload "$TAG" "${ASSETS[@]}" --repo "$REPO" --clobber
else
echo "Creating release $TAG"
# A draft, pinned to the built commit: going live is a separate, approved step, and the tag
# GitHub creates on publish lands on the commit that produced these files.
echo "Creating draft release $TAG"
gh release create "$TAG" "${ASSETS[@]}" \
--repo "$REPO" \
--draft \
--target "$COMMIT" \
--title "PseudoCode $VERSION" \
--notes "Desktop build for macOS. Signed and notarized."
fi

echo
echo "Shipped: $DMG"
echo "Published: $(gh release view "$TAG" --repo "$REPO" --json url --jq .url)"
echo "Draft: $(gh release view "$TAG" --repo "$REPO" --json url --jq .url)"
echo "Publish once approved: gh release edit $TAG --repo $REPO --draft=false --latest"
118 changes: 118 additions & 0 deletions .agents/skills/sync-upstream/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
name: sync-upstream
description: Merge upstream T3 Code (pingdotgg/t3code) into the PseudoCode fork and release the result, keeping every fork change. Use when asked to sync, pull, or merge upstream (Chris also calls it "origin", meaning the repo the fork came from) into the fork, or to cut a release after such a sync.
---

# Sync upstream

The goal is **parity**: after the sync, the fork has every upstream change and every fork change.
Fork changes are fork-only features, fork behaviour inside upstream code, and renamed product text
("PseudoCode" for "T3 Code", "Cloud Connect" for "T3 Connect").

**Names.** Chris says "origin" or "upstream" for pingdotgg/t3code. In git, that repo is the remote
`upstream`, which has pushing disabled. The git remote `origin` is the fork, pseudoseed/t3code_dev.
Every push and PR in this routine goes to the fork.

Scripts live in `.agents/skills/sync-upstream/scripts/`. Run them from the repo root.

## Steps

### 1. Branch from fork main

```sh
git fetch origin main && git fetch upstream main
git switch -c t3/sync-upstream-$(date +%Y%m%d) origin/main
git config user.email
```

The email must print `27822378+pseudoseed@users.noreply.github.com`. Chris keeps his address
private, and GitHub refuses a push whose commits carry it. Done when the branch exists and the email
matches.

### 2. Merge

```sh
git merge --no-ff upstream/main
```

Resolve every conflict so both sides survive: upstream's change and the fork's behaviour or text.
When upstream rewrote code the fork had changed, carry the fork's intent into upstream's new shape,
including its conventions (for example `Effect.fn` in place of plain arrow functions). Resolving a
whole file to one side is how fork changes go missing. Done when
`git diff --name-only --diff-filter=U` prints nothing and the merge is committed.

### 3. Prove fork changes survived

```sh
.agents/skills/sync-upstream/scripts/fork-delta-check.py -v
```

It compares the fork's changes (fork main against the last upstream commit it merged) with the
working tree and prints 2 groups:

- **DROPPED**: nothing upstream did explains the loss. Restore the line, or name in the PR body
the upstream change that does the same job.
- **REWRITTEN**: upstream rewrote the code the fork had changed. Read the new code and confirm the
fork's intent is in it.

Run it before the sync PR merges. Its defaults compare against `origin/main`, which stops holding
the old fork once the PR lands. Done when every line it lists is restored or accounted for in the PR
body.

### 4. Bump the version

Raise the version in the 4 files [ship-pseudocode](../ship-pseudocode/SKILL.md) names, and commit
it separately: `chore(release): bump PseudoCode to <version>`.

### 5. Run every check

```sh
vp run check:changed
.agents/skills/sync-upstream/scripts/run-suites.sh
```

`run-suites.sh` runs every package's tests one at a time, the one place in this repo where the full
suites run locally: Chris needs a sync green before it ships. Fix each failure at its cause. When
you believe a failure belongs to this machine rather than the code, prove it: run the same test in
a clean upstream worktree (`git worktree add ../t3-upstream-check upstream/main`, then `vp i`
there). If it fails there too, report it with that evidence as a failure, never as a pass, and
remove the worktree. Done when both commands pass, or every remaining failure carries that proof.

### 6. Check the app live

Drive web with [test-t3-app](../test-t3-app/SKILL.md) and iOS with
[test-t3-mobile](../test-t3-mobile/SKILL.md), against copied real data. Cover the fork features the
merge touched. Done when each surface was driven and what you saw is written down for the PR.

### 7. Open the PR

Push the branch to `origin` and open a PR into the fork's `main`. The body names the upstream
commit merged, the fork changes restored, each DROPPED line accepted and why, the test counts, and
what the live check covered. Chris merges it with a **merge commit**; a squash would orphan the
commits the release tag points at. Done when the PR is open. Stop there until Chris merges.

### 8. Release from merged main

```sh
git fetch origin main && git switch --detach origin/main
.agents/skills/ship-pseudocode/scripts/ship-mac.sh
.agents/skills/ship-pseudocode/scripts/ship-ios.sh
```

`ship-mac.sh` refuses any commit other than a clean `origin/main` and leaves the release as a
draft. Publish only after Chris approves that release:
`gh release edit pseudocode-v<version> --repo pseudoseed/t3code_dev --draft=false --latest`.

## When something fails

| Symptom | Cause |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A replay test hangs for 2 minutes, or mismatches on product text | Upstream's fixtures say "T3 Code" where the fork's code now says "PseudoCode" (Codex `clientInfo`, Muse `runtime_info`). Rename the text in the fixture. |
| A test expects "T3 Code" or "T3 Connect" copy | Same rename; the fork's string is the one the app shows. Update the expectation. |
| A migration test fails under Node 26 only | Node 26 bundles SQLite 3.54, which drops the JSON subtype of a subquery inside `json_set`. Wrap the subquery in `json(...)`. |
| React Compiler coverage test names a fork component | The compiler skips components with `try`/`finally`. Rewrite as a promise chain with `.finally`. |
| Lint `t3code/no-unreasoned-state-sync` on a fork effect | Upstream's rule now covers fork code. Add the reason comment the rule asks for, or remove the effect. |
| Tests killed with exit 137 | Suites ran in parallel and ran out of memory. Use `run-suites.sh`. |
| Desktop suite: "Electron failed to install correctly" after a fresh `vp i` | Parallel test workers raced to download Electron. It is installed by the end of the run; run `run-suites.sh apps/desktop` again. |
| Push declined "due to email privacy restrictions" | A commit carries Chris's private email. Fix `user.email`, then re-author only the commits not yet pushed. |
| GuestPage browser test: distance from bottom 2074, expected under 2 | Fails on this Mac on clean upstream as well, and passes on upstream's Linux CI. Not root-caused. Report it with the clean-upstream run as evidence. |
182 changes: 182 additions & 0 deletions .agents/skills/sync-upstream/scripts/fork-delta-check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""List fork changes an upstream merge dropped.

The fork's changes are the lines fork main added on top of the last upstream commit it merged.
Each one is looked up in the merge result, ignoring whitespace so upstream's reformatting is not
reported. Missing lines are sorted into two groups:

DROPPED nothing upstream did explains the loss. Restore it, or say in the PR why not.
REWRITTEN upstream rewrote or deleted the code the fork had changed. Check the fork's intent
survived in the new code; often it did, in different words.

Lines upstream itself added and later removed (the fork had them early) are counted, not listed.

fork-delta-check.py [-v] [--fork REF] [--upstream REF] [--merged REF]

Defaults: --fork origin/main, --upstream upstream/main, --merged the working tree, so it works
mid-merge with conflicts still open. Run it before the sync PR merges: afterwards origin/main
already contains the merge and there is nothing left to compare.
"""

import argparse
import os
import re
import subprocess
import sys

SKIP_PREFIXES = (".repos/", ".agent-scratch/", "pnpm-lock.yaml")
# Lines this short or this generic match somewhere in nearly every file and prove nothing.
MIN_LENGTH = 13
GENERIC = re.compile(r"^[\s{}()\[\];,.:*/`'\"<>=|&!?-]*$")
WHITESPACE = re.compile(r"\s+")
# The fork renames product text; undo it before comparing a fork line with upstream's history.
BRAND_ALIASES = (("PseudoCode", "T3 Code"), ("Cloud Connect", "T3 Connect"))


def git(*args, check=True):
result = subprocess.run(["git", *args], capture_output=True, text=True)
if check and result.returncode != 0:
sys.exit(f"git {' '.join(args)} failed:\n{result.stderr}")
return result.stdout


def squash(text):
return WHITESPACE.sub("", text)


def unbrand(text):
for fork_name, upstream_name in BRAND_ALIASES:
text = text.replace(fork_name, upstream_name)
return squash(text)


def meaningful(text):
return len(text) >= MIN_LENGTH and not GENERIC.match(text)


def fork_hunks(base, fork, path):
"""(added lines, squashed base lines around them) for each hunk of the fork's change.

The base lines are the ones the fork replaced plus 2 lines of context, so an addition made
inside a block upstream later deleted is tied to that block.
"""
hunks = []
for line in git("diff", "-U2", base, fork, "--", path).splitlines():
if line.startswith("@@"):
hunks.append(([], []))
elif not hunks or line.startswith(("+++", "---")):
continue
elif line.startswith("+"):
text = line[1:].strip()
if meaningful(text):
hunks[-1][0].append(text)
elif line.startswith(("-", " ")) and meaningful(line[1:].strip()):
hunks[-1][1].append(squash(line[1:]))
return [hunk for hunk in hunks if hunk[0]]


def merged_content(merged, path):
if merged is None:
if not os.path.isfile(path):
return None
with open(path, encoding="utf-8", errors="replace") as handle:
return handle.read()
result = subprocess.run(["git", "show", f"{merged}:{path}"], capture_output=True, text=True)
return result.stdout if result.returncode == 0 else None


def find_elsewhere(merged, line):
args = ["grep", "-l", "-F", "-e", line]
if merged is not None:
args.append(merged)
found = git(*args, check=False).split()
return [f.split(":", 1)[1] if merged is not None else f for f in found][:3]


def upstream_removed(base, upstream, paths):
"""Squashed lines upstream's own commits removed from these paths since the fork's base."""
removed = set()
log = git("log", "-p", "--no-merges", "--format=", f"{base}..{upstream}", "--", *paths)
for line in log.splitlines():
if line.startswith("-") and not line.startswith("---"):
removed.add(squash(line[1:]))
return removed


def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--fork", default="origin/main")
parser.add_argument("--upstream", default="upstream/main")
parser.add_argument("--merged", help="commit to check; default is the working tree")
parser.add_argument("-v", "--verbose", action="store_true", help="print each missing line")
args = parser.parse_args()

os.chdir(git("rev-parse", "--show-toplevel").strip())
base = git("merge-base", args.fork, args.upstream).strip()
numstat = git("diff", "--numstat", "--diff-filter=AMR", base, args.fork).splitlines()

checked = 0
missing = []
for row in numstat:
added, _, path = row.split("\t", 2)
# Binary files show "-"; renames show "old => new" and the new path is what matters.
if added == "-" or path.startswith(SKIP_PREFIXES):
continue
if " => " in path:
path = re.sub(r"\{[^}]* => ([^}]*)\}", r"\1", path).replace("//", "/")
path = path.split(" => ")[-1]
hunks = fork_hunks(base, args.fork, path)
if not hunks:
continue
checked += 1
content = merged_content(args.merged, path)
squashed = squash(content) if content is not None else ""
lost = [
(line, replaced)
for lines, replaced in hunks
for line in lines
if squash(line) not in squashed
]
if lost:
missing.append((path, sum(len(h[0]) for h in hunks), lost, content is None))

removed = upstream_removed(base, args.upstream, [m[0] for m in missing]) if missing else set()
groups = {"DROPPED": [], "REWRITTEN": []}
upstream_own = 0
for path, total, lost, gone in missing:
by_group = {"DROPPED": [], "REWRITTEN": []}
for line, replaced in lost:
if unbrand(line) in removed:
upstream_own += 1
elif any(old in removed for old in replaced):
by_group["REWRITTEN"].append(line)
else:
by_group["DROPPED"].append(line)
for group, lines in by_group.items():
if lines:
groups[group].append((path, total, lines, gone))

for group, files in groups.items():
print(f"{group}: {sum(len(f[2]) for f in files)} lines in {len(files)} files")
for path, total, lines, gone in files:
note = ""
if gone:
elsewhere = find_elsewhere(args.merged, lines[0])
note = f" (file gone; first line now in {', '.join(elsewhere)})" if elsewhere else " (file gone)"
print(f" {len(lines)}/{total}\t{path}{note}")
if args.verbose:
for line in lines[:10]:
print(f" {line[:160]}")
if len(lines) > 10:
print(f" ... {len(lines) - 10} more")
print()

print(
f"Fork delta {base[:10]}..{args.fork}, {checked} files, checked against"
f" {args.merged or 'the working tree'}. {upstream_own} missing lines were upstream's own"
" code that upstream later removed, not listed."
)


if __name__ == "__main__":
main()
Loading
Loading