Skip to content

Commit 0f4cea1

Browse files
fidgetcodingruvnet
andcommitted
chore: extract + generalize pipeline code (Agent 12)
Extract the working Obsidian <-> Notion <-> Morgen sync pipeline from the upstream private vault and land the generalized version in this repo: - src/sync-helpers.js: canonical parser/hasher/state library (verbatim, with one comment scrubbed of a real Notion DB ID). - src/auto-commit.js: generalized auto-commit daemon (env-driven paths). - scripts/morgen-backfill.js: one-shot Morgen seeder, VAULT_PATH-driven. - scripts/sync-e2e-tests.js: 12-scenario offline E2E harness. - scripts/validate-sync-state.js: standalone .sync-state.json validator. - scripts/install-workflows.sh: placeholder substitution + n8n POST. - daemon/: launchd plist template, install-daemon.sh (.app Plan B), FDA walkthrough README. - workflows/W{1,2,3}-*.json: sanitized n8n workflow exports with {{GITHUB_TOKEN}} / {{NOTION_TOKEN}} / {{MORGEN_KEY}} / {{NOTION_DATABASE_ID}} / {{GITHUB_REPO}} / {{GITHUB_OWNER}} / {{GITHUB_REPO_NAME}} placeholders. - notion/tasks-db-schema.md: required property shape + area template. - examples/: sample .env, sample sync-state.json, sample TASKS-URGENT.md. Zero real tokens, zero real IDs, zero Nathan-specific paths committed. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent f1bba71 commit 0f4cea1

22 files changed

Lines changed: 3792 additions & 5 deletions

daemon/.gitkeep

Whitespace-only changes.

daemon/README.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# task-maxxing daemon
2+
3+
A macOS LaunchAgent that watches your Obsidian vault's `08-Tasks/` directory
4+
and auto-commits + pushes every change to a git remote. Used as the trigger
5+
for workflow W1 (Obsidian → Notion/Morgen).
6+
7+
## What this does
8+
9+
1. launchd runs `src/auto-commit.js` under Node whenever a file changes
10+
inside your watch path, throttled to at most once every 30s and at least
11+
once every 5 minutes.
12+
2. The script runs `git add -A && git commit -m "auto: task edit …" && git push`.
13+
3. The push to GitHub triggers an n8n `githubTrigger` webhook (workflow W1),
14+
which parses your task files and mirrors them into Notion and Morgen.
15+
16+
## Why Plan B (wrapper `.app` bundle)?
17+
18+
The first version of this daemon used a bash script. On macOS that means
19+
granting **Full Disk Access** to `/bin/bash` — which transitively grants
20+
FDA to every bash script you ever run, forever. That's unacceptable.
21+
22+
Plan B: install a tiny `.app` bundle that wraps your Node binary. macOS TCC
23+
identifies binaries by bundle identity, so FDA on this bundle applies only
24+
to this one daemon. The launchd plist points at the bundle's binary, and
25+
launchd invokes the script through it.
26+
27+
This is what `install-daemon.sh` sets up for you.
28+
29+
## Prerequisites
30+
31+
- macOS (launchd is a macOS feature).
32+
- Node 18+ in your PATH (or set `NODE_BIN=/absolute/path/to/node`).
33+
- A local git clone of your vault (the `08-Tasks` dir must be a git repo,
34+
or be inside one).
35+
- A passwordless git remote. The daemon runs headless — it cannot answer
36+
SSH passphrase prompts. Use one of:
37+
- `gh auth login` (recommended) with HTTPS.
38+
- An SSH key that's loaded into your agent (`ssh-add`).
39+
- A PAT stored in the macOS keychain via `git credential-osxkeychain`.
40+
41+
## Install
42+
43+
```bash
44+
BUNDLE_ID=io.example.task-maxxing-daemon \
45+
WATCH_PATH="$HOME/path/to/vault/08-Tasks" \
46+
SCRIPT_PATH="$HOME/code/task-maxxing/src/auto-commit.js" \
47+
bash daemon/install-daemon.sh
48+
```
49+
50+
Required env vars:
51+
52+
| Variable | Purpose |
53+
|---------------|------------------------------------------------------------------------|
54+
| `BUNDLE_ID` | Reverse-DNS label for the launchd agent + Info.plist identifier. |
55+
| `WATCH_PATH` | Absolute path to the directory launchd should watch (your 08-Tasks). |
56+
| `SCRIPT_PATH` | Absolute path to `src/auto-commit.js` from this repo clone. |
57+
58+
Optional env vars:
59+
60+
| Variable | Default | Purpose |
61+
|-------------------|-------------------------------------------------------------|----------------------------------------|
62+
| `NODE_BIN` | `$(command -v node)` | Which Node binary to wrap. |
63+
| `APP_SUPPORT_DIR` | `$HOME/Library/Application Support/task-maxxing` | Where the wrapper `.app` is installed. |
64+
| `LOG_DIR` | `$HOME/Library/Logs` | Where launchd stdout/stderr go. |
65+
66+
The installer will:
67+
68+
1. Copy your Node binary into `~/Library/Application Support/task-maxxing/TaskMaxxingDaemon.app/Contents/MacOS/TaskMaxxingDaemon`.
69+
2. Write an `Info.plist` for the bundle.
70+
3. Render `io.example.task-maxxing-daemon.plist.template``~/Library/LaunchAgents/${BUNDLE_ID}.plist`.
71+
4. Lint the plist with `plutil`.
72+
5. Load it via `launchctl bootstrap gui/$(id -u) …`.
73+
6. Print the Full Disk Access walkthrough.
74+
75+
## Full Disk Access walkthrough
76+
77+
After the installer finishes, macOS will block the daemon from reading
78+
anything under `~/Desktop`, `~/Documents`, iCloud Drive, etc. Grant FDA:
79+
80+
1. **Open** System Settings -> Privacy & Security -> Full Disk Access.
81+
2. Click **+**.
82+
3. Press **Cmd+Shift+G** to bring up "Go to folder".
83+
4. Paste the path to the bundle the installer printed (something like
84+
`~/Library/Application Support/task-maxxing/TaskMaxxingDaemon.app`).
85+
5. Select it and click **Open**.
86+
6. **Toggle the entry ON**.
87+
7. Reload the agent so launchctl picks up the new permission:
88+
89+
```bash
90+
launchctl bootout "gui/$(id -u)/${BUNDLE_ID}"
91+
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/${BUNDLE_ID}.plist"
92+
```
93+
94+
## Verify it works
95+
96+
```bash
97+
# Watch the log.
98+
tail -f "$HOME/Library/Logs/task-maxxing.log"
99+
100+
# Touch a tracked file in the watch path.
101+
touch "$WATCH_PATH/README.md"
102+
```
103+
104+
Within 30s (the `ThrottleInterval`) you should see:
105+
106+
```
107+
[2026-04-14 11:42:01 EDT] auto-commit: README.md
108+
[2026-04-14 11:42:03 EDT] pushed successfully
109+
```
110+
111+
If you see a `FATAL: cannot read …/.git/HEAD` line, FDA isn't actually
112+
granted to the bundle — re-check steps 4–6 above. The log line includes
113+
the exact path to the Node binary you need to grant FDA to (it's inside
114+
the bundle's `Contents/MacOS/`).
115+
116+
## Uninstall
117+
118+
```bash
119+
BUNDLE_ID=io.example.task-maxxing-daemon
120+
launchctl bootout "gui/$(id -u)/${BUNDLE_ID}" || true
121+
rm -f "$HOME/Library/LaunchAgents/${BUNDLE_ID}.plist"
122+
rm -rf "$HOME/Library/Application Support/task-maxxing"
123+
```
124+
125+
Then remove the bundle entry from System Settings -> Privacy & Security ->
126+
Full Disk Access.
127+
128+
## Troubleshooting
129+
130+
- **Nothing commits, log is empty** — launchd probably refused to load the
131+
plist. Check `launchctl print gui/$(id -u)/${BUNDLE_ID}` and
132+
`log show --predicate 'subsystem == "com.apple.xpc.launchd"' --last 10m`.
133+
- **Commits happen but push fails** — the log will say `push failed — will
134+
retry next tick: …`. Usually a credential issue; try running
135+
`git -C "$WATCH_PATH" push origin main` from your terminal and see what
136+
credential prompt appears.
137+
- **Heartbeat log is quiet** — the daemon isn't firing. Check
138+
`StartInterval` and `WatchPaths` in the installed plist with
139+
`plutil -p "$HOME/Library/LaunchAgents/${BUNDLE_ID}.plist"`.

daemon/install-daemon.sh

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env bash
2+
#
3+
# install-daemon.sh — create the task-maxxing launchd agent on macOS.
4+
#
5+
# What it does:
6+
# 1. Wraps your Node binary in a tiny .app bundle (Plan B) so macOS Full
7+
# Disk Access can be granted narrowly — to this .app only — instead of
8+
# to /bin/bash or the system-wide node binary.
9+
# 2. Substitutes placeholders in io.example.task-maxxing-daemon.plist.template
10+
# from the env vars listed below.
11+
# 3. Copies the filled plist into ~/Library/LaunchAgents/.
12+
# 4. Loads the agent via `launchctl bootstrap`.
13+
# 5. Prints the Full Disk Access walkthrough.
14+
#
15+
# Required env vars:
16+
# BUNDLE_ID Reverse-DNS label (e.g. io.example.task-maxxing-daemon)
17+
# WATCH_PATH Absolute path to your vault's 08-Tasks dir (the git repo to commit)
18+
# SCRIPT_PATH Absolute path to src/auto-commit.js in your clone of this repo
19+
#
20+
# Optional env vars:
21+
# NODE_BIN Path to your Node binary (default: `command -v node`)
22+
# APP_SUPPORT_DIR Where to install the .app bundle
23+
# (default: "$HOME/Library/Application Support/task-maxxing")
24+
# LOG_DIR Where to put stdout/stderr logs
25+
# (default: "$HOME/Library/Logs")
26+
#
27+
# Usage:
28+
# BUNDLE_ID=io.example.task-maxxing-daemon \
29+
# WATCH_PATH="$HOME/path/to/vault/08-Tasks" \
30+
# SCRIPT_PATH="$HOME/code/task-maxxing/src/auto-commit.js" \
31+
# bash daemon/install-daemon.sh
32+
33+
set -euo pipefail
34+
35+
: "${BUNDLE_ID:?BUNDLE_ID env var required (e.g. io.example.task-maxxing-daemon)}"
36+
: "${WATCH_PATH:?WATCH_PATH env var required (absolute path to your 08-Tasks dir)}"
37+
: "${SCRIPT_PATH:?SCRIPT_PATH env var required (absolute path to src/auto-commit.js)}"
38+
39+
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
40+
if [[ -z "${NODE_BIN}" || ! -x "${NODE_BIN}" ]]; then
41+
echo "[install-daemon] ERROR: Node binary not found. Set NODE_BIN or install node." >&2
42+
exit 1
43+
fi
44+
45+
APP_SUPPORT_DIR="${APP_SUPPORT_DIR:-$HOME/Library/Application Support/task-maxxing}"
46+
LOG_DIR="${LOG_DIR:-$HOME/Library/Logs}"
47+
LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
48+
49+
APP_NAME="TaskMaxxingDaemon"
50+
APP_BUNDLE="${APP_SUPPORT_DIR}/${APP_NAME}.app"
51+
APP_MACOS_DIR="${APP_BUNDLE}/Contents/MacOS"
52+
NODE_APP_PATH="${APP_MACOS_DIR}/${APP_NAME}"
53+
54+
LOG_STDOUT="${LOG_DIR}/task-maxxing.stdout.log"
55+
LOG_STDERR="${LOG_DIR}/task-maxxing.stderr.log"
56+
57+
PLIST_FILENAME="${BUNDLE_ID}.plist"
58+
PLIST_TEMPLATE="$(cd "$(dirname "$0")" && pwd)/io.example.task-maxxing-daemon.plist.template"
59+
PLIST_DEST="${LAUNCH_AGENTS_DIR}/${PLIST_FILENAME}"
60+
61+
if [[ ! -f "${PLIST_TEMPLATE}" ]]; then
62+
echo "[install-daemon] ERROR: template not found: ${PLIST_TEMPLATE}" >&2
63+
exit 1
64+
fi
65+
66+
mkdir -p "${APP_MACOS_DIR}" "${LOG_DIR}" "${LAUNCH_AGENTS_DIR}"
67+
68+
# --- Step 1: build the .app bundle around the real Node binary -----------------
69+
echo "[install-daemon] creating ${APP_BUNDLE}"
70+
cp -f "${NODE_BIN}" "${NODE_APP_PATH}"
71+
chmod +x "${NODE_APP_PATH}"
72+
73+
cat > "${APP_BUNDLE}/Contents/Info.plist" <<PLIST
74+
<?xml version="1.0" encoding="UTF-8"?>
75+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
76+
<plist version="1.0">
77+
<dict>
78+
<key>CFBundleExecutable</key>
79+
<string>${APP_NAME}</string>
80+
<key>CFBundleIdentifier</key>
81+
<string>${BUNDLE_ID}</string>
82+
<key>CFBundleName</key>
83+
<string>${APP_NAME}</string>
84+
<key>CFBundlePackageType</key>
85+
<string>APPL</string>
86+
<key>CFBundleShortVersionString</key>
87+
<string>1.0</string>
88+
<key>CFBundleVersion</key>
89+
<string>1</string>
90+
<key>LSBackgroundOnly</key>
91+
<true/>
92+
</dict>
93+
</plist>
94+
PLIST
95+
96+
# --- Step 2: render the launchd plist from the template ------------------------
97+
echo "[install-daemon] rendering ${PLIST_DEST}"
98+
99+
escape_sed() {
100+
printf '%s' "$1" | sed -e 's/[&/|]/\\&/g'
101+
}
102+
103+
tmp_plist="$(mktemp)"
104+
sed \
105+
-e "s|{{BUNDLE_ID}}|$(escape_sed "${BUNDLE_ID}")|g" \
106+
-e "s|{{NODE_APP_PATH}}|$(escape_sed "${NODE_APP_PATH}")|g" \
107+
-e "s|{{SCRIPT_PATH}}|$(escape_sed "${SCRIPT_PATH}")|g" \
108+
-e "s|{{WATCH_PATH}}|$(escape_sed "${WATCH_PATH}")|g" \
109+
-e "s|{{LOG_STDOUT}}|$(escape_sed "${LOG_STDOUT}")|g" \
110+
-e "s|{{LOG_STDERR}}|$(escape_sed "${LOG_STDERR}")|g" \
111+
-e "s|{{HOME}}|$(escape_sed "${HOME}")|g" \
112+
"${PLIST_TEMPLATE}" > "${tmp_plist}"
113+
114+
mv "${tmp_plist}" "${PLIST_DEST}"
115+
chmod 644 "${PLIST_DEST}"
116+
117+
if ! /usr/bin/plutil -lint "${PLIST_DEST}" >/dev/null; then
118+
echo "[install-daemon] ERROR: plist failed lint: ${PLIST_DEST}" >&2
119+
exit 1
120+
fi
121+
122+
# --- Step 3: load into launchd -------------------------------------------------
123+
echo "[install-daemon] loading launchd agent"
124+
UID_NUM="$(id -u)"
125+
launchctl bootout "gui/${UID_NUM}" "${PLIST_DEST}" 2>/dev/null || true
126+
launchctl bootstrap "gui/${UID_NUM}" "${PLIST_DEST}"
127+
launchctl enable "gui/${UID_NUM}/${BUNDLE_ID}"
128+
129+
echo
130+
echo "[install-daemon] SUCCESS."
131+
echo " plist : ${PLIST_DEST}"
132+
echo " app bundle : ${APP_BUNDLE}"
133+
echo " node bin : ${NODE_APP_PATH}"
134+
echo " log stdout : ${LOG_STDOUT}"
135+
echo " log stderr : ${LOG_STDERR}"
136+
echo " watch path : ${WATCH_PATH}"
137+
echo
138+
139+
cat <<FDA
140+
====================================================================
141+
NEXT STEP — grant Full Disk Access to the wrapper .app
142+
143+
macOS will block the daemon from reading anything under ~/Desktop,
144+
~/Documents, iCloud, etc. until you grant Full Disk Access to the
145+
bundle we just created.
146+
147+
1. Open System Settings -> Privacy & Security -> Full Disk Access.
148+
2. Click the "+" button.
149+
3. Press Cmd+Shift+G to bring up "Go to folder".
150+
4. Paste the .app path printed above (the "app bundle" line).
151+
5. Select it and click Open.
152+
6. Toggle the entry ON.
153+
7. Reload the agent:
154+
launchctl bootout "gui/\$(id -u)/${BUNDLE_ID}"
155+
launchctl bootstrap "gui/\$(id -u)" "\$HOME/Library/LaunchAgents/${BUNDLE_ID}.plist"
156+
157+
Verify it works:
158+
tail -f "\$HOME/Library/Logs/task-maxxing.log"
159+
touch "${WATCH_PATH}/README.md"
160+
161+
You should see an "auto-commit" log line within 30s, followed by
162+
"pushed successfully".
163+
====================================================================
164+
FDA
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<!--
4+
task-maxxing launchd agent template.
5+
6+
Placeholders (replaced by daemon/install-daemon.sh at install time):
7+
{{BUNDLE_ID}} reverse-DNS label for launchd (e.g. io.example.task-maxxing-daemon)
8+
{{NODE_APP_PATH}} absolute path to the .app bundle's Contents/MacOS binary
9+
that wraps your Node binary (see daemon/README.md, Plan B)
10+
{{SCRIPT_PATH}} absolute path to src/auto-commit.js (the daemon script)
11+
{{WATCH_PATH}} absolute path to your Obsidian vault's 08-Tasks dir
12+
{{LOG_STDOUT}} absolute path for launchd stdout log
13+
{{LOG_STDERR}} absolute path for launchd stderr log
14+
{{HOME}} the installing user's home dir
15+
-->
16+
<plist version="1.0">
17+
<dict>
18+
<key>Label</key>
19+
<string>{{BUNDLE_ID}}</string>
20+
21+
<key>ProgramArguments</key>
22+
<array>
23+
<string>{{NODE_APP_PATH}}</string>
24+
<string>{{SCRIPT_PATH}}</string>
25+
</array>
26+
27+
<key>WorkingDirectory</key>
28+
<string>/tmp</string>
29+
30+
<key>StartInterval</key>
31+
<integer>300</integer>
32+
33+
<key>WatchPaths</key>
34+
<array>
35+
<string>{{WATCH_PATH}}</string>
36+
</array>
37+
38+
<key>ThrottleInterval</key>
39+
<integer>30</integer>
40+
41+
<key>RunAtLoad</key>
42+
<true/>
43+
44+
<key>StandardOutPath</key>
45+
<string>{{LOG_STDOUT}}</string>
46+
47+
<key>StandardErrorPath</key>
48+
<string>{{LOG_STDERR}}</string>
49+
50+
<key>EnvironmentVariables</key>
51+
<dict>
52+
<key>HOME</key>
53+
<string>{{HOME}}</string>
54+
<key>PATH</key>
55+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
56+
<key>TASK_MAXXING_REPO</key>
57+
<string>{{WATCH_PATH}}</string>
58+
</dict>
59+
</dict>
60+
</plist>

examples/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)