DEV Community

Riley Xu
Riley Xu

Posted on

Migration Diary: Build a Workspace Manifest Before You Leave a Paid Coding Agent

You should freeze a workspace manifest before you leave a paid coding agent, because free-tier loops rarely match the vendor file picker. Paid SDKs often choose files, skip binaries, and cap bytes without showing you the exact set. After cutover, the model either under-sees tests or over-sees lockfiles, and both failure modes look like a free-model drop. The leftover is not the model quality drop; it is an undocumented context contract you never extracted.

What breaks when the picker stays implicit

A paid coding agent usually walks your tree, respects some ignore file, and injects a subset of paths into the prompt. You never see the glob list, the per-file byte cap, or the hash of what the model actually received. When you move the loop onto a free server, you inherit none of those silent rules. The first week then fills with missing fixtures, surprise Dockerfiles, and patches that never saw the test tree.

The failure stays reproducible only if you keep receipts for every packed path and digest. Two checkouts with the same commit can still feed different bytes when one path is a symlink, a submodule, or a generated protobuf. You need a manifest that records path, size, digest, and whether the file was packed or only pointed at. Without that receipt, you cannot tell a model regression from a context regression on the same SHA.

Treat this builder as a cutover gate rather than a nice-to-have logger beside the repo. If you cannot rebuild yesterday's packed set from git SHA plus the manifest recipe, you are not ready to leave the paid SDK. Write the hash into the same diary line as the prompt version and the git SHA.

The artifact: a workspace manifest plus a packer

The script below is a labeled example you can run against a local git worktree today. It does not call any vendor API, and it writes a JSON manifest plus an optional pack of truncated text. Each entry records whether the file was packed, pointed at, skipped, or cut to a byte cap. You keep that recipe in git so two engineers can rebuild the same selection from the same commit.

#!/usr/bin/env python3
"""Build a workspace manifest before cutting an agent loop over to a free host."""
from __future__ import annotations

import hashlib
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path

ALWAYS_INCLUDE = ("README.md", "pyproject.toml", "tests/", "src/")
ALWAYS_SKIP_SUFFIX = (".png", ".jpg", ".woff", ".lock", ".min.js")
MAX_FILE_BYTES = 32_768
MAX_TOTAL_BYTES = 400_000


@dataclass
class Entry:
    path: str
    size: int
    sha256: str
    packed: bool
    truncated: bool
    reason: str


def git_root(start: Path) -> Path:
    cur = start.resolve()
    while cur != cur.parent:
        if (cur / ".git").exists():
            return cur
        cur = cur.parent
    raise SystemExit("run this inside a git worktree")


def should_skip(rel: str) -> str | None:
    parts = rel.split("/")
    if any(p in {".git", "node_modules", "dist", "__pycache__", ".venv"} for p in parts):
        return "ignored_dir"
    if rel.endswith(ALWAYS_SKIP_SUFFIX):
        return "binary_or_lock"
    return None


def force_include(rel: str) -> bool:
    return any(rel == prefix.rstrip("/") or rel.startswith(prefix) for prefix in ALWAYS_INCLUDE)


def digest_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def build_manifest(root: Path) -> dict:
    entries: list[Entry] = []
    packed_total = 0
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in {".git", "node_modules", "dist", ".venv"}]
        for name in filenames:
            full = Path(dirpath) / name
            rel = full.relative_to(root).as_posix()
            skip = should_skip(rel)
            raw = full.read_bytes()
            sha = digest_bytes(raw)
            if skip and not force_include(rel):
                entries.append(Entry(rel, len(raw), sha, False, False, skip))
                continue
            truncated = len(raw) > MAX_FILE_BYTES
            chunk = raw[:MAX_FILE_BYTES]
            would_pack = packed_total + len(chunk) <= MAX_TOTAL_BYTES
            packed = would_pack and (
                force_include(rel)
                or full.suffix in {".py", ".md", ".toml", ".txt", ".json", ".yml", ".yaml"}
            )
            reason = "packed" if packed else ("budget" if not would_pack else "not_text")
            if packed:
                packed_total += len(chunk)
            entries.append(Entry(rel, len(raw), sha, packed, truncated and packed, reason))
    entries.sort(key=lambda e: e.path)
    blob = json.dumps([asdict(e) for e in entries], separators=(",", ":")).encode()
    return {
        "root": str(root),
        "file_count": len(entries),
        "packed_count": sum(1 for e in entries if e.packed),
        "packed_bytes_capped": packed_total,
        "selection_sha256": digest_bytes(blob),
        "entries": [asdict(e) for e in entries],
    }


def write_pack(root: Path, manifest: dict, out: Path) -> None:
    out.mkdir(parents=True, exist_ok=True)
    for entry in manifest["entries"]:
        if not entry["packed"]:
            continue
        src = root / entry["path"]
        dest = out / entry["path"]
        dest.parent.mkdir(parents=True, exist_ok=True)
        data = src.read_bytes()[:MAX_FILE_BYTES]
        if entry["truncated"]:
            data += b"\n\n# [truncated by workspace-manifest]\n"
        dest.write_bytes(data)
    (out / "MANIFEST.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")


if __name__ == "__main__":
    root = git_root(Path.cwd())
    manifest = build_manifest(root)
    write_pack(root, manifest, root / ".agent-pack")
    summary = {k: manifest[k] for k in manifest if k != "entries"}
    print(json.dumps(summary, indent=2))
    print(f"wrote {root / '.agent-pack'} selection={manifest['selection_sha256'][:12]}")
Enter fullscreen mode Exit fullscreen mode

Keep the recipe next to the agent loop, and paste selection_sha256 into the cutover diary beside prompt version. If the hash moves while git SHA stays still, your ignore rules or walk order changed under you. That hash drift is a stop-the-line event during migration week, not a curiosity for later cleanup. Add .agent-pack/ to .gitignore so truncated copies never become a second source of truth in git.

Cutover plan in numbered steps

1. Capture the paid agent's hidden set

You start by collecting the path list the paid agent already used, even if the vendor never documented the picker. Run one representative task and save the trace, the prompt dump, or the UI file list into paid-seen.txt. If the product hides that list, extract path-like tokens from logs with a small parser and treat gaps as unknown rather than empty. You are gathering evidence for a diff, not trying to reimplement their ranking.

# Labeled example: extract path-like tokens from a saved trace.
python - <<'PY'
from pathlib import Path
import re
text = Path("paid-trace.txt").read_text(encoding="utf-8", errors="replace")
paths = sorted(set(re.findall(r"(?:src|tests|lib)/[A-Za-z0-9_./-]+", text)))
Path("paid-seen.txt").write_text("\n".join(paths) + "\n")
print(len(paths))
PY
Enter fullscreen mode Exit fullscreen mode

2. Build the free-loop manifest on the same commit

Check out the same SHA you used for the paid run, then generate .agent-pack/MANIFEST.json with the script. Compare those path sets with a real set difference instead of scrolling two folders by eye. A single missing tests/conftest.py will look like a model quality drop after you switch hosts later. Record packed-only paths as well, because extra lockfiles can waste the entire remaining byte budget.

git rev-parse HEAD
python workspace_manifest.py
python - <<'PY'
import json
from pathlib import Path
paid = set(Path("paid-seen.txt").read_text().split())
man = json.loads(Path(".agent-pack/MANIFEST.json").read_text())
packed = {e["path"] for e in man["entries"] if e["packed"]}
print("paid_only", sorted(paid - packed)[:20])
print("pack_only", sorted(packed - paid)[:20])
PY
Enter fullscreen mode Exit fullscreen mode

3. Decide pack, pointer, or skip for every leftover path

Paid pickers collapse three decisions into one silent include list, and you have to split them again. Pack is for source the model must edit and for tests it must not ignore during patching. Pointers are enough for binaries, lockfiles, and generated blobs whose digest still matters for audit. Skip is for secrets, editor junk, and vendor noise that previously snuck through the paid picker.

Leftover after leaving the paid SDK Symptom in the free loop Action
Vendor auto-injected tests you never listed Free loop ships untested patches Add tests/ to always-pack
Vendor skipped package-lock.json Free loop rewrites dependency pins Skip lockfiles, pointer only
Large generated api.pb.go was silently truncated Tool output becomes mid-file garbage Pointer plus hash, do not pack
Symlink to a secret file was followed Free server sees credentials Refuse follow; record as skip
Submodule directory was omitted Model invents missing adapters Pack a stub README in the submodule

4. Gate the cutover on selection hash stability

Run the builder twice on a clean tree and require identical selection_sha256 before you trust it. Dirty one tracked test file and require the hash to change, or your walk is ignoring edits. If either check fails, ignore rules or directory order are nondeterministic, and the free loop will drift. Put those two tests in CI next to the agent diary, not in a personal notebook.

# tests/test_workspace_manifest.py
# Labeled example: not executed in this article.
from pathlib import Path
from workspace_manifest import build_manifest


def _tiny_repo(base: Path) -> Path:
    (base / "src").mkdir(parents=True)
    (base / "tests").mkdir()
    (base / ".git").mkdir()
    (base / "README.md").write_text("# demo\n", encoding="utf-8")
    (base / "src" / "app.py").write_text("print(1)\n", encoding="utf-8")
    (base / "tests" / "test_app.py").write_text(
        "def test_ok():\n    assert True\n", encoding="utf-8"
    )
    return base


def test_clean_tree_is_stable(tmp_path: Path):
    repo = _tiny_repo(tmp_path)
    assert build_manifest(repo)["selection_sha256"] == build_manifest(repo)["selection_sha256"]


def test_edit_changes_selection(tmp_path: Path):
    repo = _tiny_repo(tmp_path)
    before = build_manifest(repo)["selection_sha256"]
    (repo / "src" / "app.py").write_text("# change\n", encoding="utf-8")
    assert before != build_manifest(repo)["selection_sha256"]
Enter fullscreen mode Exit fullscreen mode

5. Move the loop, keep the pack as the only context source

Point your free-tier agent at .agent-pack, not at the live worktree, until patch apply is separately gated. That isolation is the leftover paid SDKs hide behind a single index-the-repo toggle in settings. Live trees pick up unsaved buffers, user caches, and half-written refactors the model should not see. Keep the pack read-only during the first week so a runaway tool cannot enlarge its own context.

If you already have a free-tier host, you can run the packer there without changing ignore rules. MonkeyCode's free model access and free server option can run this same manifest builder against a checkout. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The script does not depend on that host; you can run it against any local worktree.

How to read the leftover report

After the set difference prints, rank paid-only paths by whether the last paid run actually edited them. If the paid agent edited a file you did not pack, add that glob before you blame the new host. If the free pack includes huge JSON fixtures the paid picker skipped, drop them to pointer status and keep the digest. Write those three decisions in the diary so the next cutover does not rediscover them blindly.

Leftovers you should expect in week one

You will still find files the paid agent saw through IDE unsaved buffers, because those buffers never hit disk. You will also find line-ending conversions on mixed Windows checkouts that change SHA without changing meaning. Normalize packer reads to LF when your team mixed editors, or the selection hash becomes a source of false alarms. Log git SHA, selection hash, and packed file count beside every free-loop run you keep.

Another leftover is prompt caching that used to key on vendor-side file indexes you cannot export. Your free loop should key cache entries on selection_sha256 plus prompt version, or you will reuse an answer against a different tree. Watch for submodule directories the paid SDK omitted while still answering as if those adapters existed. Pack a short stub in those directories so the model sees the hole instead of inventing files.

Limitations, and who should not bother

This recipe does not reconstruct a vendor's proprietary ranking of supposedly important files in your tree. It only makes your own ranking explicit, hashed, and testable on a clean checkout of the same SHA. It also does not prove the model read the packed bytes; it only proves you offered a stable set. If you need read receipts, log the later tool calls that open files after the first pack.

Do not use this approach for throwaway single-file scripts, or for repos already wrapped in a strict allowlist. Do not pack customer data, .env files, or anything your free server is not allowed to store. Raise the byte caps only after you measure real missing-context failures, not because a dashboard looks empty. Teams with a trusted vendor picker and no cutover plan should leave this gate alone for now.

The core conclusion stays the same after you delete every product name from this whole page. Freeze the files the model can see, hash that set, and refuse cutover until the hash is stable. The paid SDK made that contract invisible on purpose or by accident; your free loop cannot afford that. Ship the manifest first, then argue about model quality with evidence instead of leftover folklore.

Top comments (0)