Skip to content

Project identity: an id that survives the founder's other laptop - #111

Open
TruongGiang2000 wants to merge 12 commits into
mainfrom
feat/two-tier-memory
Open

Project identity: an id that survives the founder's other laptop#111
TruongGiang2000 wants to merge 12 commits into
mainfrom
feat/two-tier-memory

Conversation

@TruongGiang2000

Copy link
Copy Markdown
Collaborator

PR 1 of the two-tier memory feature. It gives a linked project folder a stable, opaque identity. It does not scope any memory yetDecisionEntry and ChatContext.compose are untouched. Identity has to be right first, because everything later hangs off it.

Design: docs/superpowers/specs/2026-08-21-two-tier-memory-design.md · Plan: docs/superpowers/plans/2026-08-22-two-tier-memory.md

Why an opaque id and not a path

The first draft of the design had scope holding a project path, and the cloud document keyed on a hash of one. Both are machine-specific: the same repo sits at a different path on a second machine, so every repo-scoped fact would have orphaned there — silently, which is the exact failure the feature exists to remove. It also defeated its own purpose, since syncing the project tier is worthless if the key that finds it only exists on one machine.

So a project gets an id minted once, carrying nothing about the machine. Each machine keeps its own path→id map locally; the id is what travels.

The rules this establishes

  • A remote hint may only ever propose. Adopting a match silently attaches one repo's memory to another with nothing on screen. A duplicate project is the cheaper mistake — visible and mergeable; wrongly-merged memory is neither. Two projects claiming one remote mints instead of picking.
  • Uncertainty mints. No remote, a different remote, an ambiguous one, no cloud projects at all — all mint. folderName never decides anything: two unrelated checkouts called api are ordinary.
  • activeProjectId is nil while a proposal is unanswered, so an unconfirmed guess cannot scope anything. The guard is the nil, not a flag somebody has to remember to check.
  • Signing out clears the account pointer, never the bindings. Wiping them would mean the same founder signing back in re-links their folder, mints a second id, and orphans the first id's facts.
  • The remote counts only when the folder IS the repo root. ProjectProbe checks .git in that exact folder; git remote get-url walks up. Conflating those made a folder nested inside a tracked ancestor borrow the ancestor's remote and get proposed as the ancestor's project.

Tests

88 across seven suites, 0 failures, each run per-suite (-only-testing:) because a whole-target run exits 65 on this toolchain.

Suite Tests
ProjectIdentityTests 12
ProjectIdentityMapTests 11
GitRemoteTests 10
CompanyStoreProjectIdentityTests 10
CompanyStoreChatTests 34
ChatModeEngineeringTests 9
CompanyStoreCodeRunTests 2

Two suites were reporting Executed 0 tests before this branch fixed them — CompanyStoreCodeRunTests was doing so on main, unrelated to this work. The cause was an isolated-deinit abort when a @MainActor store deallocated inside a test body; retaining the stores fixes it. ProjectIdentityMap is nonisolated for the same reason, and it needed no isolation anyway.

Also fixed: CompanyStoreChatTests was hydrating and then linking a project against the real UserDefaults, writing into cp_project_ids_v1 on every run. Four suites now inject their own suite.

Known and deliberately left

  • knownCloudProjects is empty in production until PR 2 lands the cloud write side, so only the .mint branch runs today. An id minted now is the id forever, so nothing is lost.
  • normalizeRemote leaves a port in ssh://host:2222/o/r, and bare/file:// remotes yield a path-shaped hint. All fail toward minting a duplicate — visible and mergeable.
  • resolvingSymlinksInPath() deliberately does not resolve /var, /tmp or /etc themselves, so a repo living directly under one of those could still mismatch.
  • Four test files create their own UserDefaults suite without removing it; growth is bounded only by a sweep in ProjectIdentityMapTests. Local dev hygiene, not correctness.
  • Nobody can explain why two other CompanyStore-constructing suites never needed the retain fix. Recorded rather than papered over.

Two hard preconditions for PR 2

  1. A founder-facing way to answer a proposal must exist before knownCloudProjects is ever populated. confirmProjectMatch/rejectProjectMatch have no caller outside tests. The moment that list fills, a proposal leaves activeProjectId nil with no way to answer — memory silently unscoped, permanently, for that folder.
  2. The security-scoped bookmark must be resolved on launch. linkProject writes one and nothing in the repo reads it, so activeProjectLink — and therefore activeProjectId — is nil on every cold start until the founder re-links. The spec claimed otherwise; that claim is corrected in this PR.

🤖 Generated with Claude Code

TruongGiang2000 and others added 12 commits August 22, 2026 07:56
Two errors in the first draft of the spec, both found by reading the code to
write the plan rather than by reasoning about it, both corrected in place with a
note saying where they were wrong:

The prerequisite was already built. The spec named ProjectStore.activeProjectPath
as the thing to wire into the chat lane. But CompanyStore.activeProjectLink
already exists, already persists as a security-scoped bookmark, and already
carries path/isGitRepo/hasClaudeMd — and it is the better anchor of the two,
because a linked folder is the founder's statement where an inferred cwd is a
guess about what they are doing.

The scope key was machine-specific. The spec had scope holding a project path and
the cloud document keyed on a hash of one. The same repo sits at a different path
on a second machine, so every repo-scoped fact would have orphaned there —
silently, which is the exact failure class this design exists to remove. It also
defeated its own §4.2: syncing the project tier is worthless if the key that
finds it only exists on one machine. Scope now holds an opaque minted id, and
§4.3 defines how a folder resolves to one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… that matches it

A project's identity has to survive the founder opening their other laptop. An
absolute path does not, and neither does a hash of one — the same repo lands under
two keys and every fact scoped to it is silently orphaned. So the id is minted once
and carries nothing about the machine.

normalizeRemote reduces a remote to host/owner/name so an ssh clone on one machine
and an https clone on another compare equal. Without it, protocol choice alone would
split one project in two.
Nil on every failure rather than throwing — not a repo, no remote, git missing, path
gone. A folder with no remote is an ordinary case, not an error, and every caller
handles it the same way: ask the founder instead of guessing.
The original tests could not distinguish the guard on result.ok from the
empty stdout check — both failure cases write nothing to stdout. Injecting
the runner function allows tests to construct GitResult values that fail
with non-zero exit and present stdout, proving the guard is essential.

Added three new tests:
- test_remoteURL_nilWhenTheCommandFailsEvenWithStdout (catches guard deletion)
- test_remoteURL_returnsTheTrimmedURLOnSuccess (happy path with whitespace)
- test_remoteURL_nilOnSuccessWithBlankStdout (successful command, empty output)
… orphan facts

The path names a location on one machine; the id is what DecisionEntry.scope will store
and what has to mean the same thing everywhere. Many paths mapping to one id is the
supported case — that is the whole reason for the indirection rather than scoping facts
by path directly.

Two levels, keyed by account, because a flat map has no correct behaviour on sign-out. A
project id only means something inside one founder's companies/{uid}: clear the map and
the same founder coming back re-links their folder, mints a new id, and silently orphans
every fact scoped to the old one; keep it across a switch and the next founder inherits
bindings pointing at projects they cannot see. With two levels, signing out is
account = nil and destroys nothing.

Injectable defaults for the same reason PetMemoryStore has them: a test writing to the
real key could have its cleanup clobbered by a running app and eat real bindings.
A matching git remote returns .propose, not .bound. Adopting it silently would attach
one repo's memory to another with nothing on screen — and a duplicate project is the
cheaper mistake of the two: it is visible and it can be merged, where wrongly merged
memory is neither.

Two cloud projects sharing a remote mints instead of picking one. Choosing either
would be a coin toss with the founder's facts. folderName never decides anything: two
unrelated checkouts called api are ordinary, so it is shown to the founder rather than
acted on.
…sure

activeProjectId stays nil while a proposal is unanswered. Everything downstream reads
that property, so an unconfirmed guess cannot scope a fact by accident — the guard is
the nil, not a flag somebody has to remember to check.

pendingProjectMatch carries the normalised remote that produced it, so the founder is
shown why the match was proposed instead of being asked to trust it. Rejecting mints a
fresh id rather than leaving the folder unresolved, because an unresolved folder scopes
nothing and reads as the feature quietly not working.

hydrate points the identity map at the incoming account and reset clears only that
pointer, never the bindings. Clearing them would mean the same founder signing back in
re-links their folder, mints a second id, and silently orphans every fact scoped to the
first — which is the failure this whole design exists to remove.
@mainactor gave this class an isolated deinit, and on Xcode 26.2 that stops its whole
XCTest suite from executing rather than merely crashing at teardown: the xcresult
recorded all 11 ProjectIdentityMapTests as Failed with "Executed 0 tests" (the same
pattern the pre-existing EngineeringRunStoreTests shows, so not a regression, but not
something a NEW type has to inherit either).

ProjectIdentityMap is a plain UserDefaults wrapper with no actual need for actor
isolation, so dropping @mainactor costs nothing and buys back the whole suite: 11
executed, 0 failures. CompanyStore.swift and both test files needed no adjustment —
verified via a full build and build-for-testing before running anything.
ProjectProbe checks for .git in that exact folder and does not walk up; git
remote get-url does walk up. Those are two different definitions of "is a git
repo", and reading the remote unconditionally conflated them: a folder nested
inside a tracked ancestor reported the ANCESTOR's remote as its own, and could
be proposed as the ancestor's project. The founder still had to confirm, so
nothing mis-scoped silently — but it put a wrong question in front of them with
plausible-looking evidence attached, which is the same failure wearing a human
in the loop. repoRoot compared against the linked path is what tells the two
definitions apart. A folder that is not its own root now mints instead, which is
the safe direction.

Also: a proposal now carries the path it was made for. confirmProjectMatch and
rejectProjectMatch previously resolved against activeProjectLink?.path, so a
founder who linked a second folder before answering the first proposal had their
answer applied to the wrong folder.
…anyStore-backed suite executing

Final-review fix wave over feat/two-tier-memory PR 1 (base 67084a3).

CompanyStoreChatTests hydrates a real account before calling linkProject, so its store
was binding into the app's real cp_project_ids_v1 on .standard on every run — the "these
suites never hydrate so nothing lands" comment in CompanyStoreProjectIdentityTests was
false for exactly that reason. Fixed by injecting a ProjectIdentityMap on its own test
suite there and, defensively, in ChatModeEngineeringTests and CompanyStoreCodeRunTests
too, since neither should depend on staying account-less by luck. Deleted the ~140 stray
cp.tests.*.plist files and the (already-absent) cp_project_ids_v1 key those runs left
behind, and rewrote the false comment to describe what the suites actually do now.

ProjectIdentityMapTests' `defer { UserDefaults().removePersistentDomain(forName: name) }`
never worked: it called the method on a fresh UserDefaults() instance instead of the one
that actually held the data, so the domain was never removed. Fixing that alone still
left files behind, because removePersistentDomain only empties a plist's content — it
does not delete the file — so an explicit FileManager.removeItem was added too. Even
with both fixes, cfprefsd flushes a suite's plist on its own schedule, and a late flush
can recreate the file after we deleted it (measured: 0, 0, 8, 8, 0 leftover files across
5 consecutive runs). There is no synchronous fix for that from inside the test process,
so instead of chasing a per-run guarantee this class now sweeps every cp.tests.*.plist at
the START of its run, bounding growth to at most one run's stragglers instead of the
unbounded accumulation that produced 140 files.

CompanyStoreProjectIdentityTests compiled and reported "Executed 0 tests" / exit 65 on
every run, with codepet-*.ips crash reports showing abort ->
___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED ->
swift_task_deinitOnExecutorImpl -> CompanyStore.__deallocating_deinit — CompanyStore is a
@mainactor ObservableObject with an isolated deinit, and letting one deallocate mid-test
aborts the host on this toolchain. Retaining every CompanyStore the class creates in a
never-cleared `retained` array fixes it: 10 executed, 0 failures, confirmed stable across
3 runs with zero new crash reports. Applied the same fix to CompanyStoreCodeRunTests,
which showed the identical pattern pre-existing on the base branch: now 2/2. Could not
find a clean reason ChatModeEngineeringTests and CompanyStoreChatTests run fine without
this — both also construct and deallocate CompanyStore — so that gap is reported as
unexplained rather than papered over with an invented rule.

Also: extended test_linkingWritesNothingWhileNoAccountIsSet to assert activeProjectId
stays nil (companion to the CompanyStore.swift fix in the next commit), and added
test_symlinkedPathStillMatchesTheResolvedRoot, which links via an unresolved symlink
path while injecting the RESOLVED path as forcedRoot — the case every prior test in this
file structurally could not see, since they all inject the same raw string on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ded bind, and tidy dead code

Continuing the PR 1 fix wave (base 67084a3).

linkProject compared a raw incoming path against `git rev-parse --show-toplevel`, which
canonicalises symlinks — so a folder reached through a symlinked ancestor could genuinely
BE the repo root and still fail the isRoot check, silently dropping a legitimate remote
hint. Fixed at the source: linkProject now canonicalises the path once with
URL(fileURLWithPath:).resolvingSymlinksInPath().path and uses that single canonical value
for the probe, the CLAUDE.md write, the bookmark, the isRoot comparison, and every
identityMap call downstream. Otherwise the same repo reached via a symlinked path and via
its real path would bind to two different map keys — two ids for one repo, the exact
failure this opaque id exists to prevent, and PR 1 is the cheapest point to fix it because
nothing has bound yet.

adopt(id:for:) set activeProjectId unconditionally, so a link before hydrate (or one that
outlives a sign-out) produced a live id backed by nothing on disk. It now only sets
activeProjectId when identityMap.id(forPath:) confirms the bind actually landed.

ProjectIdentityMap.reload() had no caller anywhere in the repo (verified before deleting)
and no test — it existed only because the plan's Task 5 instruction pointed at the wrong
file (PetMemoryStore.shared.reload(), which CompanyStore never calls). resetAll() stays:
also uncalled in PR 1, but it's tested and documented as the deliberate-full-reset escape
hatch a later PR is expected to wire up. CompanyStore.knownCloudProjects becomes `let` —
nothing mutates it before PR 2's cloud sync gives it a writer, and `var` was sending the
next reader hunting for one. The `nonisolated` comment on ProjectIdentityMap now says
plainly that it is main-actor-by-convention only and NOT thread-safe, so the annotation
can't be misread as a safety claim.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ecord what shipped differently

Final commit of the PR 1 fix wave (base 67084a3).

Spec §5.2 claimed the linked project "survives relaunch as a security-scoped bookmark
under cp_active_project_bookmark." It does not: linkProject writes that bookmark and
nothing in the repo ever resolves it (grep -rn resolvingBookmarkData codepet/ returns
nothing), so activeProjectLink — and therefore activeProjectId — is nil at every cold
start until the founder re-links by hand. Corrected in place, with the line's stale
references (:42, :558, :2324) replaced by symbol names, which don't rot the way line
numbers do.

Added a "PR 2 preconditions" subsection stating two hard requirements the next PR must
satisfy: a founder-facing affordance for pendingProjectMatch has to exist before
knownCloudProjects is ever populated (a proposal nobody can answer leaves activeProjectId
permanently nil), and the bookmark has to be resolved on launch or the repo tier this
whole design exists to add is empty every cold start.

Appended "Corrections applied during implementation" to the plan doc, which is committed
while the SDD ledger it was executed against is not: Task 3's flat map became
account-keyed to survive sign-out/sign-in without orphaning bindings; Task 5's two
conflicting makeStore signatures became one; the async cloud-project reader the plan
suggested stayed synchronous, since knownCloudProjects is empty in PR 1 by construction;
reload() — which the plan pointed at the wrong file for — was dropped in favour of the
account pointer CompanyStore already sets; the isGitRepo gate was removed and then
replaced by repoRoot/isRoot once a review caught the ancestor-remote hole that removal
opened; and the isRoot canonicalisation fix from this wave, which nothing had written
down before now.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant