diff --git a/active.md b/active.md index 4c93fac1..7040e4b3 100644 --- a/active.md +++ b/active.md @@ -50,3 +50,40 @@ - autolens_workspace: feature/profile-validation-resample-recovery - autogalaxy_workspace_test: feature/profile-validation-resample-recovery - autolens_workspace_test: feature/profile-validation-resample-recovery + + +## dev-workflow-helpers-laptop-paths +- issue: https://github.com/PyAutoLabs/PyAutoBrain/issues/225 +- prs: PyAutoBrain#226 (guard + installer tests) · PyAutoMind#178 (prompt_sync) +- session: Claude Code cloud session +- status: library-dev — both PRs OPEN, issue stays open until both land +- prompt: active/dev_workflow_helpers_assume_laptop_paths.md +- branch: PyAutoBrain `feature/dev-workflow-helpers-laptop-paths` (off main, files disjoint from #224 so ordering is free); PyAutoMind `claude/automind-task-planning-wxq004` +- classification: bug (PyAutoBrain primary; PyAutoMind scripts/prompt_sync.sh) +- found: 2026-08-10, three defects hit in sequence while shipping PyAutoBrain#224 — all one root cause, the helpers resolving paths under `$HOME/Code/PyAutoLabs`, which no cloud/web/CI session has. +- fixed: (1) `prompt_sync_push`/`prompt_sync_new_prompts` pushed a hardcoded `main` — now push HEAD, detached HEAD refused. (2) `worktree_check_conflict` FAILED OPEN — now exits 3 `CANNOT VERIFY`, with an explicit `--allow-missing-registry` hatch. (3) `test_skill_install.py` depended on the checkout being NAMED PyAutoBrain under `PYAUTO_ROOT` (default `bin/../..`) — now pins a fixture root. +- diagnosis-correction: the first read of (3) blamed the installer's `web-github` branch. Wrong — the real cause is `PYAUTO_ROOT` defaulting to the repo's grandparent, so a clone at lowercase `pyautobrain` never resolves `PyAutoBrain/skills` and `intake` is never installed. The environment line is a symptom of the same path resolution, not the cause. PyAutoBrain#225's body was corrected before implementation. +- test-evidence: both fixes verified to FAIL against their pre-fix code (Mind **5 of 7** new tests fail pre-fix, including the old script pushing `main -> main` from a detached HEAD; Brain guard exercised across all five paths). Corrected from '3 of 5' after the audit added a test per half of the prompt_sync defect. PyAutoBrain `pytest tests/` now 331 passed with NOTHING ignored — green in a cloud session for the first time, which retires the false-RED ship gate. +- note: `test_missing_active_md_yields_no_claims` asserted the fail-open behaviour, so it was rewritten to the corrected contract rather than worked around — it was pinning the defect. +- repos: + - PyAutoBrain: feature/dev-workflow-helpers-laptop-paths + +## reconcile-upstream-repo-mode +- issue: https://github.com/PyAutoLabs/PyAutoBrain/issues/223 +- session: Claude Code cloud session (no local worktree — see note below) +- status: library-dev +- prompt: active/reconcile_upstream_repo_mode.md +- branch: feature/reconcile-upstream-repo-mode (pushed, 875330f) +- classification: library (PyAutoBrain primary; PyAutoMind docs only) +- split-from: draft/feature/pyautomind/draft_staleness_detection_signals.md — legs 1-2 DELIVERED 2026-08-09, this is leg 3 +- conflict-check: CLEAN, but re-verified — the first run was VACUOUS. `worktree_check_conflict` resolves `active.md` under `$PYAUTO_MAIN` (default `$HOME/Code/PyAutoLabs`), which does not exist in a cloud session, so it read no file and returned 0 = "no conflict". Re-run with `PYAUTO_MAIN=/workspace` it works and confirms the real answer: the only other live claim is profile-validation-resample-recovery on PyAutoGalaxy, no overlap. With this entry's `repos:` bullet in place, a competing task on PyAutoBrain now correctly exits 1 and this same task resuming exits 0. +- GUARD-BUG (worth its own prompt): `worktree_check_conflict` FAILS OPEN when `$PYAUTO_MAIN` does not resolve — it returns 0 rather than erroring. Any cloud/CI session running the documented command gets a green light that means nothing, and two sessions could claim the same repo. Same root cause class as the `prompt_sync_push` hardcoded-`main` issue noted below. +- cloud-session-note: registered from a cloud session, where the `~/Code/PyAutoLabs-wt/` worktree model does not exist — the checkouts are /workspace/pyautobrain and /home/user/PyAutoMind. Per `start_library` step 4 (web-github / ci-only), the repos are claimed below with NO `worktree:` field. Mind changes for this task go to branch `claude/automind-task-planning-wxq004`, not to main. +- baselines-at-planning: reconcile flags 29 of 136 scanned (7 high); no `--repo` in `_intake.py` argparse; no network access anywhere under PyAutoBrain/agents/ +- status-detail: PR OPEN, awaiting review/merge. On merge the ship skills advance `active/reconcile_upstream_repo_mode.md` to `complete/2026/08/`. +- pr: https://github.com/PyAutoLabs/PyAutoBrain/pull/224 (label `pending-release`, `Closes #223`, 467+/12- across 6 files) +- implemented: 2026-08-10 on `feature/reconcile-upstream-repo-mode` (875330f). 8 new hermetic tests, 330 passed locally. Verified end-to-end against PyAutoFit fbe9f45d: `test_mode_bypass_ordered_assertion_ties.md` lands in `needs-review` with overlap_score 0.0 despite all five of its identifiers being present upstream — the acceptance criterion, met. +- ship-gate: the documented fallback gate (`pytest -x`, Heart unreachable here) returned RED on `tests/test_skill_install.py` (`assert "SKIP intake (Codex skill" in result.stdout`, 2 failures). Human acknowledged and authorized the PR. **CI then vindicated that call**: both `pytest (3.12)` and `pytest (3.13)` legs SUCCESS on 875330f, so the failures were purely local to the cloud container — `bin/install.sh` takes its `web-github / ci-only` branch (`:282`) when `$HOME/Code/PyAutoLabs` is absent, and never does the work the test asserts on. Also proven pre-existing at merge-base a50efc3 with the diff absent. +- repos: + - PyAutoBrain: feature/reconcile-upstream-repo-mode + diff --git a/active/dev_workflow_helpers_assume_laptop_paths.md b/active/dev_workflow_helpers_assume_laptop_paths.md new file mode 100644 index 00000000..9e32483f --- /dev/null +++ b/active/dev_workflow_helpers_assume_laptop_paths.md @@ -0,0 +1,139 @@ +# The dev-workflow helpers assume a laptop checkout and misbehave without it — one of them fails open + +Type: bug +Target: PyAutoBrain +Repos: +- PyAutoBrain +- PyAutoMind +Difficulty: medium +Autonomy: supervised +Priority: high +Status: planned + +Three defects, one root cause: the dev-workflow helper scripts resolve paths +under `$HOME/Code/PyAutoLabs`, which does not exist in a cloud, web or CI +session. All three were hit in a single session on 2026-08-10 while shipping +PyAutoBrain#224, and each one silently did the wrong thing rather than +complaining. + +`Priority: high` because #2 is a **safety** defect, not an inconvenience: a +guard that returns "all clear" when it cannot read its input is worse than no +guard, because the workflow documents it and skills act on its answer. + +## 1. `prompt_sync_push` hardcodes `git push origin main` + +`@PyAutoMind/scripts/prompt_sync.sh` — the helper every Mind-writing skill is +told to call: + +```bash +prompt_sync_push() { + ... + git commit -m "$subject" && \ + git push origin main ) +} +``` + +`create_issue` step 6 and `start_dev` step 7 both instruct the agent to call it. + +**Corrected 2026-08-10 after measuring the old script against real bare +remotes.** The first write-up of this defect said it "pushes Mind straight to +`main`". That is only half of it, and not the half that fires most often. +`git push origin main` pushes the local `main` *ref* — never the commit just +made on a feature branch. So the old behaviour splits by the state of local +`main`: + +| local `main` vs remote | what the old script did | +|---|---| +| **equal** (the normal case — session branched from it) | push is a **no-op**, exits **0**, and the committed work **never leaves the machine**. The caller is told the sync succeeded. | +| **ahead** (unpushed commits on `main`) | publishes those commits to remote `main` — unreviewed work bypassing review — **and still** leaves the branch work local. | + +The first row is the common one and the quieter danger: in an ephemeral cloud +container, "committed, reported synced, never pushed" is silent loss. The second +row is the review-bypass. Both are the same root cause and both are fixed by +pushing `HEAD`. + +Fix: push the current branch (`git push -u origin HEAD`), or refuse with a clear +error when the checkout is not on `main`. Do not leave this to operator +vigilance — that is the same reasoning that motivated the `pre_build` staging +fix (PyAutoHands#233). + +## 2. `worktree_check_conflict` FAILS OPEN — the important one + +`@PyAutoBrain/bin/worktree.sh` — `worktree_list_claimed` reads `active.md` under +`$PYAUTO_MAIN` (default `$HOME/Code/PyAutoLabs`): + +```bash +if [[ ! -f "$active" ]]; then + return 0 +fi +``` + +No file → returns 0 → `worktree_check_conflict` reports **no conflict**. So in +any environment where the roots are not at the default path, the guard cannot +distinguish "nothing claims this repo" from "I could not read the registry", and +answers the former. + +Reproduced 2026-08-10: `worktree_check_conflict reconcile-upstream-repo-mode +PyAutoBrain PyAutoMind` returned 0 and was recorded as a clean conflict check. +Re-run with `PYAUTO_MAIN=/workspace` it actually worked and returned the same +verdict — but the first answer was worth nothing, and if a conflict HAD existed +the session would have started conflicting work with a green light. + +This is the guard that serialises two agents wanting the same repo. Two sessions +claiming one repo is exactly the class of collision the worktree flow exists to +prevent. + +Fix: fail closed. If the registry cannot be resolved, return non-zero with a +message naming the path it tried, so the caller stops rather than proceeds. A +`--allow-missing-registry` escape hatch is acceptable only if it must be passed +explicitly. + +## 3. `test_skill_install.py` fails locally but passes in CI + +`@PyAutoBrain/tests/test_skill_install.py` — two tests assert on installer output +that only appears on the local-dev path: + +```python +assert "SKIP intake (Codex skill" in result.stdout +``` + +`@PyAutoBrain/bin/install.sh` already knows the difference — it prints +`Environment: local-dev (PyAuto repos detected)` or `Environment: web-github / +ci-only (clone roots on demand)` — and on the web-github path it skips the +skill-symlink work these tests assert on. So they fail in any cloud session and +pass in real CI. + +Consequence, observed: PyAutoHeart is unreachable from a cloud session, so +`ship_library` falls back to `pytest -x` as the ship gate and treats any failure +as RED. These two failures made that gate spuriously RED on PyAutoBrain#224 and +cost a human acknowledgement to override. CI on that PR then went green on both +legs, confirming the diff was never implicated. + +Fix: skip (not fail) when the installer reports the web-github path. The tests +are asserting local-dev behaviour and should say so. + +## The unifying point + +`install.sh` already detects and prints its execution environment, and +`WORKFLOW.md` already names the environments (`local-dev`, `web-github`, +`ci-only`). The helpers should consult that same notion and either adapt or fail +closed. None of the three should silently do the wrong thing. + +## Acceptance + +- `prompt_sync_push` on a non-`main` branch pushes **that branch** — so the + committed work actually reaches the remote — and never advances `main` as a + side effect. Both halves covered by their own test, since they are separate + failures of the same bug. +- `worktree_check_conflict` returns **non-zero** when it cannot resolve + `active.md`, naming the path it tried. Covered by a test that points + `$PYAUTO_MAIN` at an empty dir. The "registry present, no claim" case still + returns 0, so the existing behaviour is unchanged where it worked. +- `test_skill_install.py` passes on both paths — skipping, with a reason, when + the installer reports web-github; still asserting fully on local-dev. +- `pytest tests/` is green in a cloud session, so the `ship_library` fallback + gate stops producing false REDs. + + diff --git a/active/reconcile_upstream_repo_mode.md b/active/reconcile_upstream_repo_mode.md new file mode 100644 index 00000000..b12849c1 --- /dev/null +++ b/active/reconcile_upstream_repo_mode.md @@ -0,0 +1,144 @@ +# `intake reconcile --repo ` — read the target repo, the only route to the findings the Mind cannot see + +Type: feature +Target: PyAutoBrain +Repos: +- PyAutoBrain +- PyAutoMind +Difficulty: medium +Autonomy: supervised +Priority: high +Status: planned + +Leg 3 of `draft/feature/pyautomind/draft_staleness_detection_signals.md`, split +out because that prompt's legs 1 and 2 are **delivered** and one prompt is one +task is one PR. Read the parent for the labelled set and the measurement; this +file is the remaining leg only. Do not re-open the parent. + +## Why this leg exists, and why it cannot be skipped + +The parent's § Hard limit is the argument. Of the five confirmed findings from +the 2026-08-09 `draft/` sweep, **two left no Mind-local evidence at all**: + +- `rectangular_adapt_constant_split_guard` — its evidence sat inside a sibling + *prompt*, not a completion record, so nothing that reads `complete/` could + reach it. +- `latent_samples_none_on_resumed_fit` — PyAutoFit#1418 fixed it the same day it + was filed and **no completion record was written**. There was nothing in the + Mind to cross-reference. (A record exists now — `complete/2026/07/ + latent-samples-none-on-resumed-fit.md` — but only because a human did the + upstream read by hand. That is precisely the work this leg mechanises, and the + next such fix will land with no record again.) + +The parent proved that no amount of re-ranking Mind-local signals reaches these: +every attempt cost precision without gaining truth. The upstream read is +load-bearing. This leg is that read. + +Current state to build on: `intake reconcile` flags **29 suspects of 135 +scanned** (7 `high`, 22 `medium`) — the post-re-rank numbers holding. The +ranking is not the problem any more; the missing evidence source is. + +## Scope + +Add an opt-in upstream mode to +`@PyAutoBrain/agents/conductors/intake/_intake.py`: + +``` +pyauto-brain intake reconcile --repo [prefix] +``` + +It resolves the target to a GitHub slug, makes a cached shallow clone of that +repo's `main`, extracts the backticked identifiers each prompt names, greps the +clone, and ranks on hits — reporting `file:line` evidence a human can read. + +### Target resolution + +`repos.yaml` in @PyAutoMind carries the `github:` slug for every repo, and +`@PyAutoBrain/config/policy.yaml` already carries `repo_aliases` +(`pyautoarray -> autoarray`, and the sizing faculty's `PyAutoFit -> autofit` +package form). Reuse both — do not add a third mapping. + +**Multi-repo pseudo-targets must be refused, not guessed.** `workspaces`, +`health_fixes`, `priors`, `graphical_ep` are not repos, and they are among the +*largest* buckets in `draft/` (`workspaces` alone spans 22 prompts across four +work-types). `--repo workspaces` must exit non-zero naming the real candidate +repos. Silently picking one would produce confident nonsense on the biggest +part of the backlog. + +### Clone mechanics + +Plain `--depth 1` with `GIT_LFS_SKIP_SMUDGE=1`, cached per-repo and re-fetched +on later runs. Record the resolved sha in the output so a verdict is +reproducible and re-checkable. + +**Correction to the parent prompt:** it suggests `--depth 1 --filter=blob:none`. +Treeless is wrong here — grepping source refetches every blob on demand, so it +is a false economy. Use a plain shallow clone. + +### The signal + +Extract backticked `snake_case` / `CamelCase` identifiers from the prompt and +grep the clone for them. `_IDENT_RE` in `_intake.py` already does exactly this +extraction for the Mind-local leg; reuse it rather than writing a second regex. + +The parent proposes scoping extraction to the prompt's `## Scope` / +`## Acceptance` sections, to catch "identifiers the prompt says do not exist +yet". That rule needs a stated fallback: **the trap prompt below has neither +heading** — its only heading is a dated correction block — so a strictly +section-scoped rule no-ops on exactly the prompts that matter most. + +## The trap — verified, and the acceptance criterion turns on it + +`draft/bug/autofit/test_mode_bypass_ordered_assertion_ties.md` names five +identifiers: `FitException`, `check_assertions`, `ignore_assertions`, +`instance_for_arguments`, `instance_from_vector`. **All five are on PyAutoFit +`main`.** The prompt is confirmed **NOT** shipped: `main` catches +`exc.FitException` in the TEST_MODE bypass, which looks exactly like the +requested fix, but the catch wraps only the likelihood call while +`model.instance_from_vector` — where `check_assertions` actually raises on an +ordering tie — sits on the line *before* the `try`. + +A naive identifier-presence matcher scores this 5 of 5 and calls it shipped. It +would be wrong, and it is the single mis-grade this tool must never make. + +So: **the mode reports `needs-review` with evidence, never a `shipped` +verdict.** It ranks for a human and retires nothing — which is already +reconcile's stated contract (`retiring a prompt stays human`). Keep it. + +## Architectural constraint — this is the Brain's first network access + +Nothing under `@PyAutoBrain/agents/` currently uses `urllib`, `requests`, `gh` +or `git clone`. Every conductor and faculty is stdlib-only and offline. This +leg breaks that invariant, so: + +- the flag is **strictly opt-in**; the default `reconcile` path stays byte-for- + byte offline and read-only; +- the upstream read sits behind an **injectable seam** (a source-reader + callable), so `@PyAutoBrain/tests/test_intake_reconcile_ranking.py` stays + hermetic — every fixture there is a fictional Mind tree in `tmp_path`, and + that property is worth more than integration coverage; +- one opt-in network integration test, gated on an env var, covers the real + clone path; +- `@PyAutoBrain/AUTONOMY.md` gets a line stating the network surface and that it + is opt-in. + +## Acceptance + +- `--repo ` resolves via `repos.yaml` + `policy.yaml` `repo_aliases`, + and **refuses** `workspaces` / `health_fixes` / `priors` / `graphical_ep` + non-zero, naming the real repos. +- Run against PyAutoFit, `draft/bug/autofit/test_mode_bypass_ordered_assertion_ties.md` + is **NOT** reported as shipped, despite all five of its identifiers being + present upstream. Pinned by a test. +- No prompt is moved or retired by the tool. Pinned by a test (the parent + already asserts this for the Mind-local legs). +- The default `reconcile` path performs **no** network access. Pinned by a test. +- The existing hermetic tests still pass unchanged. +- Output records the resolved upstream sha. + + diff --git a/draft/feature/pyautomind/draft_staleness_detection_signals.md b/draft/feature/pyautomind/draft_staleness_detection_signals.md index 15f04c1f..3c558814 100644 --- a/draft/feature/pyautomind/draft_staleness_detection_signals.md +++ b/draft/feature/pyautomind/draft_staleness_detection_signals.md @@ -180,6 +180,11 @@ is already reconcile's stated contract. Keep it. > > Leg 3 remains open and is now better motivated: it is the *only* route to the > last two findings. +> +> **Leg 3 was split out on 2026-08-10** to +> `draft/feature/pyautobrain/reconcile_upstream_repo_mode.md` — one prompt is +> one task is one PR, and legs 1-2 here are delivered. Track leg 3 there; do +> not re-open this prompt for it. - Re-running the ranker against PyAutoMind `f25e154e` (the pre-sweep tree, which is the labelled set this prompt establishes) puts every **Mind-reachable** diff --git a/scripts/prompt_sync.sh b/scripts/prompt_sync.sh index 49081433..21a977f4 100755 --- a/scripts/prompt_sync.sh +++ b/scripts/prompt_sync.sh @@ -45,6 +45,28 @@ _prompt_sync_require_repo() { fi } +# Push the checkout's CURRENT branch — never a hardcoded `main`. +# +# Both helpers below used to end in `git push origin main` regardless of what +# was checked out. `create_issue` step 6 and `start_dev` step 7 both instruct an +# agent to call them, so a branch-scoped session (a cloud session with a +# designated branch, or any PR-based flow) that followed the documented steps +# verbatim pushed Mind straight to `main`, bypassing review entirely. +# +# Pushing HEAD leaves the single-branch laptop flow byte-identical — there the +# current branch IS `main` — while making a branch-scoped checkout do the right +# thing instead of the dangerous thing. +_prompt_sync_push_current() { + local branch + branch=$(git -C "$PROMPT_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null) + if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then + echo "prompt_sync: refusing to push from a detached HEAD in $PROMPT_REPO" >&2 + echo " check out a branch first." >&2 + return 1 + fi + git -C "$PROMPT_REPO" push -u origin "$branch" +} + # Commit and push any new untracked .md files at the repo root or under # category dirs as one "sync new task ideas" commit. Each new file is listed # individually in the commit body so the history shows which prompts arrived. @@ -69,8 +91,8 @@ prompt_sync_new_prompts() { body=$(echo "$untracked" | sed 's/^/- /') ( cd "$PROMPT_REPO" && git add -- $untracked && \ - git commit -m "$(printf 'prompt: sync new task ideas\n\n%s\n' "$body")" && \ - git push origin main ) + git commit -m "$(printf 'prompt: sync new task ideas\n\n%s\n' "$body")" ) && \ + _prompt_sync_push_current } # Stage any modifications under the repo, commit with the given subject, @@ -79,9 +101,12 @@ prompt_sync_new_prompts() { prompt_sync_push() { _prompt_sync_require_repo || return 1 local subject="${1:-prompt: sync PyAutoMind}" - ( cd "$PROMPT_REPO" && \ - git add -A && \ - if git diff --cached --quiet; then return 0; fi && \ - git commit -m "$subject" && \ - git push origin main ) + git -C "$PROMPT_REPO" add -A || return 1 + # Still a no-op when nothing is staged — the early return must come BEFORE the + # push, or a caller with no changes would push anyway. + if git -C "$PROMPT_REPO" diff --cached --quiet; then + return 0 + fi + git -C "$PROMPT_REPO" commit -m "$subject" || return 1 + _prompt_sync_push_current } diff --git a/tests/test_prompt_sync_branch.py b/tests/test_prompt_sync_branch.py new file mode 100644 index 00000000..44d3e74c --- /dev/null +++ b/tests/test_prompt_sync_branch.py @@ -0,0 +1,184 @@ +"""`prompt_sync.sh` must push the branch it is ON, never a hardcoded `main`. + +Both helpers used to end in `git push origin main` regardless of what was +checked out. `create_issue` step 6 and `start_dev` step 7 both instruct an agent +to call them, so a branch-scoped session — a cloud session with a designated +branch, or any PR-based flow — that followed the documented steps verbatim +pushed Mind straight to `main`, bypassing review entirely. + +These tests drive the real script against throwaway git repos with real bare +remotes, because that is the only way to observe *which ref actually moved*. A +test that merely greps the source for the string would pass on a script that +still pushed the wrong branch by another route. + +Fictional fixtures only, per `test_spawn_privacy.py` — `tests/**` is KEEP-copied +verbatim into the public template, so nothing here names a real task or prompt. +""" + +import subprocess +from pathlib import Path + +SYNC = Path(__file__).resolve().parents[1] / "scripts" / "prompt_sync.sh" + + +def _git(repo, *args): + return subprocess.run(["git", "-C", str(repo), *args], + capture_output=True, text=True, check=True).stdout.strip() + + +def _repo_with_remote(tmp_path): + """A checkout on `main` with one commit, wired to a real bare remote.""" + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], + capture_output=True, check=True) + repo = tmp_path / "mind" + subprocess.run(["git", "init", "-b", "main", str(repo)], + capture_output=True, check=True) + _git(repo, "config", "user.email", "test@example.invalid") + _git(repo, "config", "user.name", "Test") + (repo / "seed.md").write_text("# seed\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "seed") + _git(repo, "remote", "add", "origin", str(remote)) + _git(repo, "push", "-u", "origin", "main") + return repo, remote + + +def _run(repo, snippet): + """Source the real script against `repo` and run `snippet`.""" + return subprocess.run( + ["bash", "-c", f'set -e; export PROMPT_REPO="{repo}"; ' + f'source "{SYNC}"; {snippet}'], + capture_output=True, text=True) + + +def _remote_branches(remote): + out = subprocess.run(["git", "-C", str(remote), "for-each-ref", + "--format=%(refname:short)", "refs/heads/"], + capture_output=True, text=True, check=True).stdout + return set(out.split()) + + +def test_push_from_a_feature_branch_does_not_touch_main(tmp_path): + """THE defect. On a branch-scoped checkout the helper must move that + branch's ref and leave `main` exactly where it was.""" + repo, remote = _repo_with_remote(tmp_path) + main_before = _git(remote, "rev-parse", "main") + + _git(repo, "checkout", "-b", "claude/some-task") + (repo / "active.md").write_text("# Active\n\n## a-task\n") + + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + assert "claude/some-task" in _remote_branches(remote) + assert _git(remote, "rev-parse", "main") == main_before, ( + "main moved — the helper pushed the wrong branch") + + +def test_committed_work_actually_reaches_the_remote(tmp_path): + """The failure the old script hit MOST often, and the quietest one. + + `git push origin main` from a feature branch pushes the local `main` ref — + not the commit just made. When local `main` already matches the remote (the + normal case for a session that branched from it) the push is a no-op, exits + 0, and the committed work never leaves the machine. The caller is told the + sync succeeded. In an ephemeral cloud container that is silent loss. + + Leaking to `main` is the other half of the same bug and needs local `main` + to be ahead — see `test_a_branch_push_never_advances_main` below. This test + is the common case: the work must be ON the remote afterwards. + """ + repo, remote = _repo_with_remote(tmp_path) + _git(repo, "checkout", "-b", "claude/some-task") + (repo / "active.md").write_text("# Active\n\n## a-task\n") + + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + local_head = _git(repo, "rev-parse", "HEAD") + assert _git(remote, "rev-parse", "claude/some-task") == local_head, ( + "the commit never reached the remote") + + +def test_a_branch_push_never_advances_main(tmp_path): + """The other half: an unpushed commit sitting on local `main` must not be + published as a side effect of syncing a feature branch. + + The old script pushed it — unreviewed work onto `main`, while the branch + work it was actually asked to sync stayed local. + """ + repo, remote = _repo_with_remote(tmp_path) + (repo / "secret.md").write_text("unreviewed local work\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "local main work") + main_before = _git(remote, "rev-parse", "main") + + _git(repo, "checkout", "-b", "claude/some-task") + (repo / "active.md").write_text("# Active\n") + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + assert _git(remote, "rev-parse", "main") == main_before, ( + "an unpushed local main commit was published as a side effect") + assert "claude/some-task" in _remote_branches(remote) + + +def test_push_on_main_still_pushes_main(tmp_path): + """The single-branch laptop flow must be unchanged: there the current + branch IS main, so the fix is a no-op for it.""" + repo, remote = _repo_with_remote(tmp_path) + main_before = _git(remote, "rev-parse", "main") + + (repo / "active.md").write_text("# Active\n") + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + assert _git(remote, "rev-parse", "main") != main_before + assert _remote_branches(remote) == {"main"} + + +def test_push_is_still_a_no_op_when_nothing_is_staged(tmp_path): + """The documented contract. Guards a regression the branch fix can easily + introduce: if the early return moves after the push, a caller with no + changes pushes anyway.""" + repo, remote = _repo_with_remote(tmp_path) + _git(repo, "checkout", "-b", "claude/some-task") + + res = _run(repo, 'prompt_sync_push "prompt: nothing to do"') + assert res.returncode == 0, res.stderr + + assert _remote_branches(remote) == {"main"}, ( + "a no-op call created a remote branch") + + +def test_new_prompts_sweep_also_pushes_the_current_branch(tmp_path): + """The sibling helper carried the same hardcoded push.""" + repo, remote = _repo_with_remote(tmp_path) + main_before = _git(remote, "rev-parse", "main") + + _git(repo, "checkout", "-b", "claude/some-task") + draft = repo / "draft" / "feature" / "widget" + draft.mkdir(parents=True) + (draft / "a_new_idea.md").write_text("# A new idea\n") + + res = _run(repo, "prompt_sync_new_prompts") + assert res.returncode == 0, res.stderr + + assert "claude/some-task" in _remote_branches(remote) + assert _git(remote, "rev-parse", "main") == main_before + + +def test_detached_head_is_refused(tmp_path): + """`push -u origin HEAD` from a detached HEAD is meaningless — say so + rather than guessing a branch.""" + repo, _ = _repo_with_remote(tmp_path) + (repo / "active.md").write_text("# Active\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "second") + _git(repo, "checkout", "--detach", "HEAD") + (repo / "active.md").write_text("# Active\n\nchanged\n") + + res = _run(repo, 'prompt_sync_push "prompt: from detached"') + assert res.returncode != 0 + assert "detached HEAD" in res.stderr