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
35 changes: 24 additions & 11 deletions packages/agent-memory-sync/docs/machine-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ empty/stale local workspace as if it were authoritative.
Claude Code slug for `rootDir` — do not guess it from the other
profiles' slugs, since it encodes this machine's own OS username and
checkout path (see the namespace-divergence note inside any existing
profile file).
profile file). The template's `syncPaths` already includes the
`machine-state` entry (see (e) below) alongside `memory` and
`frictions`; fill in its `<linux-username>` placeholder like the rest
of the file rather than adding the entry by hand.
4. First pull so the machine starts from the mini's current state:
```bash
agent-memory-sync run <profile-name> --config profiles/linux.json --mode pull
Expand Down Expand Up @@ -333,12 +336,13 @@ bullet at the top of this document.
## e) machine-state payload (toolchain snapshots)

Every committed machine profile (`profiles/mac-mini.json`,
`profiles/macbook.json`, `profiles/linux.json`)
carries a **second, independent** `syncPaths` entry alongside the `memory`
one described at the top of this document:
`profiles/macbook.json`, `profiles/linux.json`), and the template,
`profiles/linux.example.json`, carries a **second, independent**
`syncPaths` entry alongside the `memory` one described at the top of this
document:

```json
{ "source": "/Users/<user>/.harness/machine-state", "destination": "machine-state", "kind": "directory" }
{ "source": "/Users/<user>/.harness/machine-state", "destination": "machine-state", "kind": "directory", "ownerScoped": true }
```

Unlike the `memory` entry, `source` here is an **absolute path outside
Expand All @@ -350,12 +354,21 @@ treats an absolute `source` as-is instead of resolving it against `rootDir`,
so this entry syncs on its own schedule independent of the memory tree; both
entries still land under the same shared `pandora/` remote tree
(`repositorySubdir`), just under different top-level destinations
(`pandora/memory/...` vs. `pandora/machine-state/...`).
(`pandora/memory/...` vs. `pandora/machine-state/...`). In
`profiles/linux.example.json` the entry's `source` uses the same
`<linux-username>` placeholder as `rootDir`/`stateDir` (see (c) above);
copying the template and filling in that one placeholder is all a new
machine needs to do here, rather than adding this entry from scratch.

**Payload convention — one file per machine, owner-writes-only.** Each
machine writes exactly one JSON file under its own `machine-state/`,
named after its own profile (`machine-state/mac-mini.json`,
`machine-state/macbook.json`, ...). A machine never writes to another
`machine-state/macbook.json`, ...). This is enforced, not just documented:
the syncPaths entry above sets `"ownerScoped": true`, which is the
mechanism that makes push only ever offer this machine's own
`<profile>.json`, never a peer's file this machine merely pulled (see
`collectLocalSyncFiles`'s `ownerFilter` option in
`src/memory-sync/config.ts`). A machine never writes to another
machine's file — this makes *content* conflicts on this path structurally
impossible (`inline-markers` conflict resolution is never invoked here in
practice, unlike the `memory` tree where concurrent edits are expected).
Expand Down Expand Up @@ -403,12 +416,12 @@ exist ahead of any sync.
Every committed profile (`profiles/mac-mini.json`, `profiles/macbook.json`,
`profiles/linux.json`, `profiles/linux.example.json`) also carries an
**independent** `syncPaths` entry pointing at `~/.harness/frictions` — the
third entry in `mac-mini.json`/`macbook.json`/`linux.json` (after `memory`
and `machine-state`), and the second in `linux.example.json` (that template
does not yet include a `machine-state` entry):
third entry in every one of them, after `memory` and `machine-state` (see
(e) above; the template carries a placeholder `machine-state` entry too, so
its `syncPaths` array has the same three-entry shape as the real profiles):

```json
{ "source": "/Users/<user>/.harness/frictions", "destination": "frictions", "kind": "directory" }
{ "source": "/Users/<user>/.harness/frictions", "destination": "frictions", "kind": "directory", "ownerScoped": true }
```

Same convention as the machine-state payload in section e) above: one file
Expand Down
18 changes: 16 additions & 2 deletions packages/agent-memory-sync/profiles/linux.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"recorded as a cosmetic label inside this machine's state.json. Fill it",
"in anyway, for a self-documenting invocation and a correct label.",
"",
"syncPaths is a single directory entry covering the ENTIRE rootDir",
"syncPaths' FIRST entry is a directory entry covering the ENTIRE rootDir",
"(source '.'), so every file agent-memory-sync finds there is synced,",
"including future subdirectories, without listing individual",
"files/folders by name. destination is the non-'.' literal 'memory', not",
Expand All @@ -51,6 +51,19 @@
"see isHiddenEntryName in src/memory-sync/config.ts and",
"src/memory-sync/git-client.ts.",
"",
"syncPaths' SECOND entry is machine-state (absolute source",
"/home/<linux-username>/.harness/machine-state, destination",
"'machine-state'), carrying this machine's toolchain snapshot for the",
"cross-machine parity check run by the harness companion 'session-start",
"toolchain-parity'; see docs/machine-setup.md section e) for the full",
"payload convention (one file per machine, owner-writes-only) and",
"section f) for the THIRD entry, frictions. Both entries set",
"\"ownerScoped\": true, matching the real linux.json/macbook.json/",
"mac-mini.json profiles, so push only ever offers this machine's own",
"<profile>.json under machine-state/ or frictions/ and never a peer's",
"file this machine merely pulled; see collectLocalSyncFiles'",
"ownerFilter option in src/memory-sync/config.ts.",
"",
"stateDir is set OUTSIDE rootDir (a separate ~/.agent-memory-sync tree),",
"which matters now that syncPaths covers the whole rootDir: if stateDir",
"stayed nested inside rootDir, the recursive walk would also pick up",
Expand Down Expand Up @@ -82,6 +95,7 @@
"reachabilityTimeoutMs": 5000,
"syncPaths": [
{ "source": ".", "destination": "memory", "kind": "directory" },
{ "source": "/home/<linux-username>/.harness/frictions", "destination": "frictions", "kind": "directory" }
{ "source": "/home/<linux-username>/.harness/machine-state", "destination": "machine-state", "kind": "directory", "ownerScoped": true },
{ "source": "/home/<linux-username>/.harness/frictions", "destination": "frictions", "kind": "directory", "ownerScoped": true }
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,39 @@ const { cloneRemote, createSandbox, fileExists, initBareRemote, readText, runCli

const PROFILES_DIR = path.resolve(process.cwd(), "profiles");

// Derives the committed profile file list structurally (readdirSync over
// profiles/*.json) instead of a hand-maintained array, so a future profile
// (including a new machine or a template edit) is picked up by every test
// below that iterates "all committed profiles" without a matching edit
// here. Deliberately includes linux.example.json: the template is a
// committed *.json file under profiles/ like any other.
//
// A purely structural derivation loses deletion/rename detection though:
// an empty or partially-emptied profiles/ directory would silently make
// every test below iterate over fewer (or zero) files and report green
// instead of catching the loss. The non-vacuity guard below restores that
// detection while still auto-picking-up any future profile: it requires
// at least the 4 known files to be present, by name, on every call.
function listProfileFiles(): string[] {
const files = readdirSync(PROFILES_DIR)
.filter((name: string) => name.endsWith(".json"))
.sort();

const knownProfiles = ["macbook.json", "mac-mini.json", "linux.json", "linux.example.json"];
assert.ok(
files.length >= 4,
`profiles/ must contain at least the ${knownProfiles.length} known machine profiles, found ${files.length}: ${JSON.stringify(files)} (PROFILES_DIR: ${PROFILES_DIR})`
);
const missing = knownProfiles.filter((name) => !files.includes(name));
assert.equal(
missing.length,
0,
`profiles/ is missing known machine profile(s): ${JSON.stringify(missing)}, found: ${JSON.stringify(files)} (PROFILES_DIR: ${PROFILES_DIR})`
);

return files;
}

function machineArgs(
profileName: string,
configPath: string,
Expand Down Expand Up @@ -129,13 +162,14 @@ test("macbook and mac-mini profiles share one remote tree and see each other's p
test("all committed profiles (macbook, mac-mini, linux, linux.example) declare the same repositorySubdir", () => {
// A narrower, faster companion to the end-to-end test above: pins the
// specific config field that caused the divergence directly against the
// committed files, independent of any CLI/git plumbing. Includes
// linux.example.json — the copy-paste source for any new machine — so a
// future template edit that reintroduces a per-machine placeholder (as
// this template originally had, mirroring the pre-fix macbook/mac-mini
// profiles) is caught here too, not just on the profiles already in
// active use.
const profileFiles = ["macbook.json", "mac-mini.json", "linux.json", "linux.example.json"];
// committed files, independent of any CLI/git plumbing. profileFiles is
// derived structurally (readdirSync, see listProfileFiles above), so it
// always includes linux.example.json (the copy-paste source for any new
// machine) without listing filenames by hand: a future template edit
// that reintroduces a per-machine placeholder (as this template
// originally had, mirroring the pre-fix macbook/mac-mini profiles) is
// caught here too, not just on the profiles already in active use.
const profileFiles = listProfileFiles();
const settingsByFile = Object.fromEntries(
profileFiles.map((file) => [file, JSON.parse(readText(path.join(PROFILES_DIR, file)))])
);
Expand Down Expand Up @@ -195,11 +229,16 @@ test("all committed profiles (macbook, mac-mini, linux, linux.example) declare t
);
}

// Same pin for the pre-existing machine-state entry, closing the identical
// #64 coverage gap — every committed machine profile, but not
// linux.example.json, since the template has never carried a machine-state
// entry (linux.json adds it deliberately; see machine-setup.md section f).
for (const file of ["macbook.json", "mac-mini.json", "linux.json"]) {
// Same pin for the machine-state entry, closing the identical #64
// coverage gap for every committed profile, including linux.example.json:
// the template now carries the entry too (placeholder <linux-username>
// source), precisely so a future third machine copied from it starts with
// the entry present instead of repeating the hand-patch divergence
// linux.json/macbook.json/mac-mini.json needed before this fix (agent-tasks
// 10df0d9d; see machine-setup.md section e)/f)). The endsWith check below
// tolerates the template's placeholder segment (the path is still
// absolute-shaped and still ends with the literal suffix).
for (const file of profileFiles) {
const machineStateEntries = findEntriesByDestination(settingsByFile[file].syncPaths, "machine-state");
assert.equal(
machineStateEntries.length,
Expand All @@ -219,18 +258,74 @@ test("all committed profiles (macbook, mac-mini, linux, linux.example) declare t
}
});

// Pins the three path invariants documented in every profile's "//" field
// (real profiles' "rootDir/stateDir use a resolved absolute path, not '~'"
// paragraph; the template's identical paragraph): rootDir/stateDir must be
// absolute, must not start with '~' (agent-memory-sync's config loader
// never expands it; see resolveRunConfig() in src/config/loader.ts, which
// treats a leading '~' as a literal path segment, not the home directory),
// and stateDir must sit OUTSIDE rootDir (otherwise the recursive '.'
// syncPaths walk would also pick up this tool's own state (queue/base/tmp)
// and try to sync it as memory content). profileFiles is the same
// structurally-derived list used above, so linux.example.json is included:
// its placeholder segments (e.g. '<linux-username>', '<linux-hostname>')
// keep both paths absolute-shaped (still start with '/') and un-prefixed by
// '~', and its stateDir/rootDir still resolve to different subtrees, so all
// three checks hold for the template's placeholder values with no
// special-casing: these are pure string checks, never filesystem lookups,
// so an unresolved placeholder segment does not make them fail or need to
// be skipped.
test("all committed profiles keep rootDir/stateDir absolute, un-expanded (no leading '~'), and stateDir outside rootDir", () => {
const profileFiles = listProfileFiles();
const settingsByFile = Object.fromEntries(
profileFiles.map((file) => [file, JSON.parse(readText(path.join(PROFILES_DIR, file)))])
);

for (const file of profileFiles) {
const { rootDir, stateDir } = settingsByFile[file];

assert.ok(
typeof rootDir === "string" && path.isAbsolute(rootDir),
`profiles/${file} rootDir must be an absolute path, got: ${JSON.stringify(rootDir)}`
);
assert.ok(
typeof stateDir === "string" && path.isAbsolute(stateDir),
`profiles/${file} stateDir must be an absolute path, got: ${JSON.stringify(stateDir)}`
);
assert.ok(
!(rootDir as string).startsWith("~"),
`profiles/${file} rootDir must not start with '~' (the config loader never expands it), got: ${rootDir}`
);
assert.ok(
!(stateDir as string).startsWith("~"),
`profiles/${file} stateDir must not start with '~' (the config loader never expands it), got: ${stateDir}`
);

const rel = path.posix.relative(path.posix.normalize(rootDir as string), path.posix.normalize(stateDir as string));
assert.ok(
rel !== "" && (rel.startsWith("../") || path.posix.isAbsolute(rel)),
`profiles/${file} stateDir must sit OUTSIDE rootDir, got rootDir: ${rootDir}, stateDir: ${stateDir}`
);
}
});

// Pins Defect B's fix (agent-tasks 06d09cde / .ai/runs/2026-08-03-sync-conflict-markers-echo,
// D-002/D-003): machine-state and frictions are one-owner-file-per-machine
// destinations, so push must never re-offer a peer's file it only pulled —
// ownerScoped: true on both entries in every real machine profile is what
// ownerScoped: true on both entries in every committed profile is what
// makes collectLocalSyncFiles' ownerFilter (src/memory-sync/config.ts)
// actually engage. Deliberately scoped to the same 3 real profiles as the
// machine-state pin above, not linux.example.json — the template documents
// the convention but was never a live sync target, so it carries no
// machine-state entry at all and this task's brief only requires the flag on
// "die 3 committeten Profilen".
test("macbook, mac-mini, and linux profiles set ownerScoped: true on both their machine-state and frictions entries", () => {
const profileFiles = ["macbook.json", "mac-mini.json", "linux.json"];
// actually engage. profileFiles is the same structurally-derived list used
// by the tests above (see listProfileFiles), so it now includes
// linux.example.json too: an earlier revision of this test hand-scoped the
// list to the 3 real profiles and excluded the template, because the
// template's machine-state entry set ownerScoped: true (mirroring the real
// profiles, agent-tasks 10df0d9d) while its frictions entry still did not;
// including the template would have failed on that entry alone. The
// template's frictions entry now sets ownerScoped: true too, closing that
// gap, so the exclusion is no longer needed and a future regression in the
// template is caught here like any real profile.
test("all committed profiles set ownerScoped: true on both their machine-state and frictions entries", () => {
const profileFiles = listProfileFiles();
const settingsByFile = Object.fromEntries(
profileFiles.map((file) => [file, JSON.parse(readText(path.join(PROFILES_DIR, file)))])
);
Expand Down
Loading