From df3e18d12ca751c41d7ce7659a4802171e0ddc6d Mon Sep 17 00:00:00 2001 From: Lan Nguyen Si Date: Mon, 27 Jul 2026 13:51:48 +0700 Subject: [PATCH 1/2] feat(memory-sync): frictions syncPaths entry in all machine profiles Third syncPaths entry per profile (source ~/.harness/frictions, destination frictions, directory, not required), following the machine-state pattern from #64. New machine-setup.md section f) documents the one-file-per-machine payload convention and names the producer (friction-log sync-export, config-gated, agent-dx); the stale '(upcoming)' wording for the shipped toolchain-parity companion is fixed. New integration test pins missing-source tolerance and the push/pull round-trip incl. mkdir-on-pull. Note: linux.example.json carries the frictions entry as its second syncPaths entry, since that template never had a machine-state entry. Refs: agent-tasks task 343d5a8f-b01b-400d-ba0e-c69191e1220a Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FFEBioagwFtGfZVmAvECPu --- .../agent-memory-sync/docs/machine-setup.md | 27 +++- .../profiles/linux.example.json | 3 +- .../agent-memory-sync/profiles/mac-mini.json | 3 +- .../agent-memory-sync/profiles/macbook.json | 5 + .../integration/frictions-syncpath.test.ts | 140 ++++++++++++++++++ 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts diff --git a/packages/agent-memory-sync/docs/machine-setup.md b/packages/agent-memory-sync/docs/machine-setup.md index e8cf049..fd10ed2 100644 --- a/packages/agent-memory-sync/docs/machine-setup.md +++ b/packages/agent-memory-sync/docs/machine-setup.md @@ -337,7 +337,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 @@ -390,3 +390,28 @@ 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//.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. The producer is the +`friction-log sync-export` command (config-gated, shipped in `agent-dx`, +not in this repo); this 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. diff --git a/packages/agent-memory-sync/profiles/linux.example.json b/packages/agent-memory-sync/profiles/linux.example.json index 89362d1..22b85b1 100644 --- a/packages/agent-memory-sync/profiles/linux.example.json +++ b/packages/agent-memory-sync/profiles/linux.example.json @@ -81,6 +81,7 @@ "conflictStrategy": "inline-markers", "reachabilityTimeoutMs": 5000, "syncPaths": [ - { "source": ".", "destination": "memory", "kind": "directory" } + { "source": ".", "destination": "memory", "kind": "directory" }, + { "source": "/home//.harness/frictions", "destination": "frictions", "kind": "directory" } ] } diff --git a/packages/agent-memory-sync/profiles/mac-mini.json b/packages/agent-memory-sync/profiles/mac-mini.json index de12022..891a90f 100644 --- a/packages/agent-memory-sync/profiles/mac-mini.json +++ b/packages/agent-memory-sync/profiles/mac-mini.json @@ -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" } ] } diff --git a/packages/agent-memory-sync/profiles/macbook.json b/packages/agent-memory-sync/profiles/macbook.json index a8e450f..36d8959 100644 --- a/packages/agent-memory-sync/profiles/macbook.json +++ b/packages/agent-memory-sync/profiles/macbook.json @@ -110,6 +110,11 @@ "source": "/Users/lan/.harness/machine-state", "destination": "machine-state", "kind": "directory" + }, + { + "source": "/Users/lan/.harness/frictions", + "destination": "frictions", + "kind": "directory" } ] } \ No newline at end of file diff --git a/packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts b/packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts new file mode 100644 index 0000000..3f3b2c0 --- /dev/null +++ b/packages/agent-memory-sync/tests/integration/frictions-syncpath.test.ts @@ -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"); + } +); From 06701161a5af73e2e3533c9aecf8d46f14854433 Mon Sep 17 00:00:00 2001 From: Lan Nguyen Si Date: Mon, 27 Jul 2026 14:06:19 +0700 Subject: [PATCH 2/2] test(memory-sync): pin committed profiles' frictions + machine-state entries; docs precision Review round: the integration test alone was inert against a revert of the profile entries, so the settingsByFile parity test now reads the committed profiles directly and pins the frictions entry on all three (destination, kind, absolute source suffix, not required) and the machine-state entry on macbook/mac-mini; proven load-bearing by a temporary entry-strip that failed exactly this assertion. Five doc fixes in machine-setup.md: stale single-entry claim, restore-recipe destination note, watch caveats in f), concrete producer convention (sync_export.path, no derived filename, write-through from all six mutating commands), and the free-text exposure note. Refs: agent-tasks task 343d5a8f-b01b-400d-ba0e-c69191e1220a Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FFEBioagwFtGfZVmAvECPu --- .../agent-memory-sync/docs/machine-setup.md | 67 +++++++++++++------ .../cross-machine-profiles.test.ts | 61 +++++++++++++++++ 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/packages/agent-memory-sync/docs/machine-setup.md b/packages/agent-memory-sync/docs/machine-setup.md index fd10ed2..a3970b5 100644 --- a/packages/agent-memory-sync/docs/machine-setup.md +++ b/packages/agent-memory-sync/docs/machine-setup.md @@ -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 @@ -298,10 +301,12 @@ is `agent-memory-sync restore`: # of guessing why a short one did not resolve. agent-memory-sync restore --config profiles/.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/, -# 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/, not just the +# bare filename: agent-memory-sync restore --config profiles/.json --path memory/MEMORY.md # Roll back the entire synced tree (requires --yes) @@ -408,10 +413,34 @@ 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. The producer is the -`friction-log sync-export` command (config-gated, shipped in `agent-dx`, -not in this repo); this 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. +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/.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/.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/.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/.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. diff --git a/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts b/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts index 17da935..c06bfc9 100644 --- a/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts +++ b/packages/agent-memory-sync/tests/integration/cross-machine-profiles.test.ts @@ -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> | undefined, + destination: string + ): Array> { + 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}` + ); + } });