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
80 changes: 67 additions & 13 deletions packages/agent-memory-sync/docs/machine-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ This document wires together the pieces already documented individually
per-machine value (mirroring the profile name) — that made every machine
write to its own top-level tree in the bare repo and none of them ever
saw each other's pushes, caught by a live cross-machine E2E test
(`pull` reporting `applied=0`). `syncPaths` in every profile is a single
`source: "."` directory entry with `destination: "memory"`, so the whole
`rootDir` (flat `.md` files, no fixed subdirectory layout) syncs to
`pandora/memory/...` in the bare repo, including any file added later. The
directory walk skips hidden files/dot-directories (`.DS_Store`, `._*`
AppleDouble shadows, `.git`, ...) by design — macOS cruft never becomes a
synced file or a recurring inline-conflict-marker diff between machines —
and it never follows symlinks under `rootDir` either (silently skipped,
(`pull` reporting `applied=0`). The `memory` entry in every profile's
`syncPaths` — a `source: "."` directory entry with `destination:
"memory"` — covers the whole `rootDir` (flat `.md` files, no fixed
subdirectory layout), so it syncs to `pandora/memory/...` in the bare
repo, including any file added later (profiles carry further,
independent `syncPaths` entries beyond this one — see (e) and (f)
below). The directory walk skips hidden files/dot-directories
(`.DS_Store`, `._*` AppleDouble shadows, `.git`, ...) by design — macOS
cruft never becomes a synced file or a recurring inline-conflict-marker
diff between machines — and it never follows symlinks under `rootDir`
either (silently skipped,
not descended into; intentional containment). See `isHiddenEntryName` in
`src/memory-sync/config.ts` / `src/memory-sync/git-client.ts`.
- Conflict strategy is `inline-markers` everywhere — concurrent edits that
Expand Down Expand Up @@ -298,10 +301,12 @@ is `agent-memory-sync restore`:
# of guessing why a short one did not resolve.
agent-memory-sync restore <sha> --config profiles/<name>.json --dry-run

# Roll back a single file — --path is relative to repositorySubdir, and
# every profile's syncPaths destination is "memory" (see the shared remote
# tree bullet at the top of this document), so the path is memory/<file>,
# not just the bare filename:
# Roll back a single file — --path is relative to repositorySubdir. Only
# the memory tree's syncPaths destination is "memory" (see the shared
# remote tree bullet at the top of this document); the other payloads
# (machine-state, frictions) use their own destinations instead — see
# (e)/(f) below. So a memory file's path is memory/<file>, not just the
# bare filename:
agent-memory-sync restore <sha> --config profiles/<name>.json --path memory/MEMORY.md

# Roll back the entire synced tree (requires --yes)
Expand Down Expand Up @@ -337,7 +342,7 @@ one described at the top of this document:
Unlike the `memory` entry, `source` here is an **absolute path outside
`rootDir`** — `~/.harness/machine-state`, not anywhere under the Claude Code
memory directory — pointing at a per-machine toolchain-snapshot directory
maintained by the (upcoming) harness companion `session-start
maintained by the (shipped) harness companion `session-start
toolchain-parity`. `resolveWorkspacePath` in `src/memory-sync/config.ts`
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
Expand Down Expand Up @@ -390,3 +395,52 @@ peer's snapshot is pulled down, so no manual `mkdir` step is required during
machine setup either — though the harness companion consuming these files
may still create the directory itself on first run if it expects it to
exist ahead of any sync.

## f) frictions payload (friction-log exports)

Every committed profile (`profiles/mac-mini.json`, `profiles/macbook.json`,
`profiles/linux.example.json`) also carries an **independent** `syncPaths`
entry pointing at `~/.harness/frictions` — the third entry in
`mac-mini.json`/`macbook.json` (after `memory` and `machine-state`), and
the second in `linux.example.json` (that template does not yet include a
`machine-state` entry):

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

Same convention as the machine-state payload in section e) above: one file
per machine, named after its own profile (`frictions/mac-mini.json`,
`frictions/macbook.json`, ...), owner-writes-only, and never any secret —
the directory is synced into the shared, committed remote, so anything
dropped there ends up in every peer's git history.

**Producer convention.** Each machine configures `friction-log`'s
`sync_export.path` setting to `~/.harness/frictions/<its own profile>.json`
(e.g. `~/.harness/frictions/mac-mini.json`) — `friction-log` writes exactly
that configured path and never derives a filename of its own, so the
owner-writes-only convention above is carried entirely by that per-machine
config, not by anything in this package. The file is also kept current via
write-through from `friction-log`'s six mutating commands, not only by the
dedicated `sync-export` verb, so `frictions/<profile>.json` can reflect a
mutation made through any of those commands, not just an explicit export
step. This producer (`friction-log`, config-gated) is shipped in
`agent-dx`, not in this repo; this `syncPaths` entry only carries the
resulting file, it does not generate it.

The same missing-source tolerance and `mkdir -p`-on-pull behavior described
above for `machine-state` apply here unchanged, since both are plain
non-required, absolute-source, directory-kind `syncPaths` entries handled
by the same code path. The same `watch` caveats from section e) apply too:
`watch` only pushes and never pulls, so a peer's `frictions/<peer>.json`
only shows up locally after the next `pull`/`sync`; and `watch` only
detects this machine's own very first local write into `frictions/` if
`~/.harness` already existed when `watch` started — on a truly fresh
machine the first export travels via the periodic sync job or after a
`watch` restart instead.

**Exposure note.** Friction-log records are free text. Like every other
payload in this document, anything written into
`~/.harness/frictions/<profile>.json` is synced into the shared, committed
remote and therefore counts as published into every peer's git history —
redact anything sensitive before logging it, not after.
3 changes: 2 additions & 1 deletion packages/agent-memory-sync/profiles/linux.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"conflictStrategy": "inline-markers",
"reachabilityTimeoutMs": 5000,
"syncPaths": [
{ "source": ".", "destination": "memory", "kind": "directory" }
{ "source": ".", "destination": "memory", "kind": "directory" },
{ "source": "/home/<linux-username>/.harness/frictions", "destination": "frictions", "kind": "directory" }
]
}
3 changes: 2 additions & 1 deletion packages/agent-memory-sync/profiles/mac-mini.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"reachabilityTimeoutMs": 5000,
"syncPaths": [
{ "source": ".", "destination": "memory", "kind": "directory" },
{ "source": "/Users/lannguyensi/.harness/machine-state", "destination": "machine-state", "kind": "directory" }
{ "source": "/Users/lannguyensi/.harness/machine-state", "destination": "machine-state", "kind": "directory" },
{ "source": "/Users/lannguyensi/.harness/frictions", "destination": "frictions", "kind": "directory" }
]
}
5 changes: 5 additions & 0 deletions packages/agent-memory-sync/profiles/macbook.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@
"source": "/Users/lan/.harness/machine-state",
"destination": "machine-state",
"kind": "directory"
},
{
"source": "/Users/lan/.harness/frictions",
"destination": "frictions",
"kind": "directory"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,65 @@ test("profiles/macbook.json, profiles/mac-mini.json, and profiles/linux.example.
"in src/memory-sync/config.ts); the 'profile' field is local-only and may differ"
);
}

// Pin the frictions syncPaths entry (agent-tasks 343d5a8f) directly
// against each committed profile file — deliberately placed in THIS test,
// not the live push/pull E2E test above, because this one parses the
// profile JSON via settingsByFile and never touches a real machine's home
// directory, so it stays green even where the E2E test's EACCES failure
// (mac-mini.json's hardcoded /Users/lannguyensi paths on a machine that
// isn't the mini) applies. Without this block, reverting the three
// profiles' new syncPaths entry left the suite fully green — nothing else
// reads the committed profile files for this entry.
function findEntriesByDestination(
syncPaths: Array<Record<string, unknown>> | undefined,
destination: string
): Array<Record<string, unknown>> {
return (syncPaths || []).filter((entry) => entry.destination === destination);
}

for (const file of profileFiles) {
const frictionsEntries = findEntriesByDestination(settingsByFile[file].syncPaths, "frictions");
assert.equal(
frictionsEntries.length,
1,
`profiles/${file} must declare exactly one syncPaths entry with destination 'frictions', found ${frictionsEntries.length}`
);
const [frictionsEntry] = frictionsEntries;
assert.equal(frictionsEntry.kind, "directory", `profiles/${file} frictions entry must be kind 'directory'`);
assert.ok(
typeof frictionsEntry.source === "string" && path.isAbsolute(frictionsEntry.source as string),
`profiles/${file} frictions entry source must be an absolute path, got: ${JSON.stringify(frictionsEntry.source)}`
);
assert.ok(
(frictionsEntry.source as string).endsWith("/.harness/frictions"),
`profiles/${file} frictions entry source must end with '/.harness/frictions', got: ${frictionsEntry.source}`
);
assert.ok(
!frictionsEntry.required,
`profiles/${file} frictions entry must not be required (missing-source tolerance is the point)`
);
}

// Same pin for the pre-existing machine-state entry, closing the identical
// #64 coverage gap — macbook.json and mac-mini.json only, since
// linux.example.json's template has never carried a machine-state entry.
for (const file of ["macbook.json", "mac-mini.json"]) {
const machineStateEntries = findEntriesByDestination(settingsByFile[file].syncPaths, "machine-state");
assert.equal(
machineStateEntries.length,
1,
`profiles/${file} must declare exactly one syncPaths entry with destination 'machine-state', found ${machineStateEntries.length}`
);
const [machineStateEntry] = machineStateEntries;
assert.equal(machineStateEntry.kind, "directory", `profiles/${file} machine-state entry must be kind 'directory'`);
assert.ok(
typeof machineStateEntry.source === "string" && path.isAbsolute(machineStateEntry.source as string),
`profiles/${file} machine-state entry source must be an absolute path, got: ${JSON.stringify(machineStateEntry.source)}`
);
assert.ok(
(machineStateEntry.source as string).endsWith("/.harness/machine-state"),
`profiles/${file} machine-state entry source must end with '/.harness/machine-state', got: ${machineStateEntry.source}`
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Pins the frictions syncPaths convention (agent-tasks 343d5a8f: "agent-memory-sync:
// frictions syncPaths-Eintrag in allen Maschinen-Profilen"): a third,
// independent directory-kind syncPaths entry whose `source` is an ABSOLUTE
// path OUTSIDE rootDir — mirroring profiles/mac-mini.json, profiles/macbook.json,
// and profiles/linux.example.json's real frictions entry, which points at
// ~/.harness/frictions rather than anywhere under the Claude Code memory
// rootDir. This is the exact PR-#64 machine-state-syncpath pattern (see
// tests/integration/machine-state-syncpath.test.ts) applied to the frictions
// payload — same two guarantees, both already true of
// collectLocalSyncFiles/mapRemotePathToLocalAbsolute in
// src/memory-sync/config.ts and src/memory-sync/pull.ts WITHOUT any code
// change:
// 1. A directory-kind entry with an absolute `source` syncs correctly
// end-to-end through push+pull alongside sibling entries —
// resolveWorkspacePath (src/memory-sync/config.ts) treats an absolute
// candidate as-is instead of resolving it against rootDir.
// 2. Neither `push` nor `pull` breaks when that absolute source directory
// does not exist locally yet: config.ts's collectLocalSyncFiles skips a
// non-required syncPaths entry whose source is missing (existsSync
// check, `continue`), and pull's writer
// (mkdirSync(path.dirname(...), { recursive: true }) in
// src/memory-sync/pull.ts) creates that absolute directory the first
// time a peer's snapshot is pulled — so a fresh machine's frictions
// directory need not pre-exist before its first pull.
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const {
cloneRemote,
createSandbox,
fileExists,
initBareRemote,
readText,
runCli,
writeProjectConfig,
writeText
} = require("../helpers/cli.ts");

function twoEntryConfig(
workspaceRoot: string,
remoteDir: string,
stateDir: string,
frictionsSource: string
) {
return {
rootDir: workspaceRoot,
remoteUrl: remoteDir,
branch: "main",
repositorySubdir: "shared",
stateDir,
conflictStrategy: "inline-markers",
syncPaths: [
{ source: "MEMORY.md", destination: "MEMORY.md", kind: "file" },
{ source: frictionsSource, destination: "frictions", kind: "directory" }
]
};
}

test("push tolerates a second syncPaths directory entry whose absolute source does not exist locally yet", () => {
const root = createSandbox("frictions-missing-source");
const remoteDir = initBareRemote(root);
const workspaceRoot = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const configPath = path.join(root, "config.json");
// Absolute path outside rootDir/workspaceRoot, deliberately never
// created — mirrors a fresh machine that has not written a friction-log
// export to ~/.harness/frictions yet.
const frictionsSource = path.join(root, "harness-frictions");

writeText(path.join(workspaceRoot, "MEMORY.md"), "seed\n");
writeProjectConfig(configPath, twoEntryConfig(workspaceRoot, remoteDir, stateDir, frictionsSource));
assert.equal(fileExists(frictionsSource), false);

const result = runCli(["run", "default", "--config", configPath, "--mode", "push", "--output", "json"]);
const payload = JSON.parse(result.stdout);

assert.equal(payload.runs[0].status, "applied");
assert.ok(
payload.runs[0].appliedFiles.some((f: string) => f.endsWith("MEMORY.md")),
`expected MEMORY.md in appliedFiles: ${JSON.stringify(payload.runs[0].appliedFiles)}`
);
assert.ok(
!payload.runs[0].appliedFiles.some((f: string) => f.startsWith("frictions/")),
`missing source should not have synced anything: ${JSON.stringify(payload.runs[0].appliedFiles)}`
);

const inspection = cloneRemote(remoteDir, root, "inspect-missing-source");
assert.equal(fileExists(path.join(inspection, "shared", "frictions")), false);
});

test(
"push and pull round-trip a directory syncPaths entry whose source is absolute and outside rootDir, " +
"and pull creates that directory locally if missing (mkdir -p semantics)",
() => {
const root = createSandbox("frictions-roundtrip");
const remoteDir = initBareRemote(root);

// Machine A already has a friction-log export on disk and pushes it.
const workspaceA = path.join(root, "workspace-a");
const stateDirA = path.join(root, "state-a");
const configPathA = path.join(root, "config-a.json");
const frictionsSourceA = path.join(root, "machine-a-harness-frictions");

writeText(path.join(workspaceA, "MEMORY.md"), "machine a memory\n");
writeText(path.join(frictionsSourceA, "mac-mini.json"), '{"frictions":[]}\n');
writeProjectConfig(configPathA, twoEntryConfig(workspaceA, remoteDir, stateDirA, frictionsSourceA));

const pushResult = runCli(["run", "default", "--config", configPathA, "--mode", "push", "--output", "json"]);
const pushPayload = JSON.parse(pushResult.stdout);
assert.equal(pushPayload.runs[0].status, "applied");
assert.ok(
pushPayload.runs[0].appliedFiles.includes("frictions/mac-mini.json"),
`expected frictions/mac-mini.json in appliedFiles: ${JSON.stringify(pushPayload.runs[0].appliedFiles)}`
);

// Machine B has never written to its frictions source directory — it
// (and its rootDir) do not exist on disk at all before the pull below,
// matching a brand-new machine's first `run --mode pull`.
const workspaceB = path.join(root, "workspace-b");
const stateDirB = path.join(root, "state-b");
const configPathB = path.join(root, "config-b.json");
const frictionsSourceB = path.join(root, "machine-b-harness-frictions");

writeProjectConfig(configPathB, twoEntryConfig(workspaceB, remoteDir, stateDirB, frictionsSourceB));
assert.equal(fileExists(frictionsSourceB), false);
assert.equal(fileExists(workspaceB), false);

const pullResult = runCli(["run", "default", "--config", configPathB, "--mode", "pull", "--output", "json"]);
const pullPayload = JSON.parse(pullResult.stdout);
assert.equal(pullPayload.runs[0].status, "applied");
assert.ok(
pullPayload.runs[0].appliedFiles.includes("frictions/mac-mini.json"),
`expected frictions/mac-mini.json in pull appliedFiles: ${JSON.stringify(pullPayload.runs[0].appliedFiles)}`
);

assert.equal(fileExists(frictionsSourceB), true);
assert.equal(readText(path.join(frictionsSourceB, "mac-mini.json")), '{"frictions":[]}\n');
assert.equal(readText(path.join(workspaceB, "MEMORY.md")), "machine a memory\n");
}
);
Loading