diff --git a/CHANGELOG.md b/CHANGELOG.md index a0131bc..3eae4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,17 @@ All notable changes to `task-maxxing` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] — 2026-05-04 + +### Removed (BREAKING) +- **Notion is dropped from the kit.** The W3 workflow (Notion → Obsidian) and `notion/tasks-db-schema.md` have been deleted. The W1 workflow no longer makes Notion API calls — its `Code` node is renamed `Parse + Sync to Morgen` and the `notionApi` credential branch is now a tripwire that throws if anything reaches it. +- W0 orchestrator: rewired from `Every 15m → W2 → W3 → W1` to `Every 20m → W2 → W1`. The W3 step is gone. +- `examples/sample-sync-state.json`: every `notionPageId` is `null`. The field is preserved as nullable on each entry for backward-compat with pre-cutover entries; no code path reads or writes it post-cutover. +- `examples/sample-.env.example`: `NOTION_TOKEN` and `NOTION_DATABASE_ID` are commented as deprecated. Leave them unset on fresh installs. +- README: tagline + workflow table + project tree updated 3-way → 2-way. A top-of-README NOTE banner explains the cutover. + +### Why +- Kit author's own instance (`obsidian-tasks-sync`) dropped Notion on the same date after the Notion bearer in W1+W3 was found to be silently 401-ing on every orchestrator tick. Morgen and Obsidian had become the only legs that mattered. The full investigation is in the kit author's vault under `05-Projects/LAVA-NET/invoices/2026-05-04-morgen-task-creation-and-sync-diagnosis.md` and the cutover memory at `project_notion_drop_2026_05_04.md`. ### Added - README: social-links badge strip (X · LinkedIn · YouTube · Instagram, ruvnet-style for-the-badge) inserted into the centered header block beneath the project license badge. diff --git a/README.md b/README.md index 2b36fa2..4c66ebd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@ ![task-maxxing](https://raw.githubusercontent.com/lorecraft-io/task-maxxing/main/taskmaxxing.png) -**Perfect three-way task sync between Obsidian, Notion, and Morgen — a DIY kit.** +**Two-way task sync between Obsidian and Morgen — a DIY kit.** + +> [!NOTE] +> **2026-05-04 cutover:** Notion was dropped from this kit. The maintained pipeline is now **Obsidian ↔ Morgen** two-way. The W3 (Notion → Obsidian) workflow has been removed and W1 no longer touches the Notion API. If you cloned the kit before this date and want the original three-way mode, pin to a commit on `main` before this banner. References to Notion still appear in some doc files (ARCHITECTURE.md, DESIGN-RATIONALE.md, etc.) — those sections are kept as historical context but the working code is now two-way. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) @@ -24,18 +27,18 @@ | Link | Section | What it does | Time | |---|---|---|---| | [What this is](#what-this-is) | Overview | The TL;DR — what the kit actually does | ~1 min | -| [Do you actually need this?](#do-you-actually-need-a-three-way-sync) | Audience | ADHD honesty check — who this is (and isn't) for | ~1 min | -| [Why not just...](#why-not-just) | Context | Why Notion / Obsidian / Motion / Zapier alone all fail | ~2 min | +| [Do you actually need this?](#do-you-actually-need-a-two-way-sync) | Audience | ADHD honesty check — who this is (and isn't) for | ~1 min | +| [Why not just...](#why-not-just) | Context | Why Obsidian / Motion / Zapier alone all fail | ~2 min | | [What you actually get](#what-you-actually-get) | Reference | Edit-anywhere, canonical source, git history, no lock-in | ~1 min | -| [Architecture](#architecture) | Overview | Six directed edges, three workflows, one daemon | ~1 min | -| [Workflow glossary](#workflow-glossary) | Reference | W1 / W2 / W3 — what each triggers and does | ~1 min | +| [Architecture](#architecture) | Overview | Four directed edges, two workflows, one daemon | ~1 min | +| [Workflow glossary](#workflow-glossary) | Reference | W1 / W2 — what each triggers and does | ~1 min | | [Daemon (local, macOS)](#daemon-local-macos) | Reference | The only thing that touches your filesystem | ~1 min | -| [Prerequisites](#prerequisites) | Setup | Accounts + tools (Obsidian, Notion, Morgen, n8n, GitHub, macOS) | ~2 min | +| [Prerequisites](#prerequisites) | Setup | Accounts + tools (Obsidian, Morgen, n8n, GitHub, macOS) | ~2 min | | [Quickstart](#quickstart) | Setup | Clone → env → daemon → backfill → n8n → smoke test | ~2 min | | [What's in the box](#whats-in-the-box) | Reference | Repo file-tree tour | ~1 min | | [Status](#status) | Meta | Alpha — running on my vault, looking for testers | ~1 min | | [Known quirks](#known-quirks) | Reference | macOS-only daemon, Morgen inbox-only, rate budget | ~1 min | -| [Using these tools outside the sync](#using-these-tools-outside-the-sync) | Reference | Optional Morgen / Notion MCPs + Obsidian download | ~1 min | +| [Using these tools outside the sync](#using-these-tools-outside-the-sync) | Reference | Optional Morgen MCP + Obsidian download | ~1 min | | [The maxxing series](#the-maxxing-series) | Meta | Sibling repos: cli-maxxing + creativity-maxxing | — | | [License](#license) | Meta | MIT | — | | [Credits](#credits) | Meta | Built by Nate Davidovich | — | @@ -49,34 +52,33 @@ ## What this is -`task-maxxing` keeps one task in sync across the three apps I actually live in: **Obsidian** (my files), **Notion** (the pretty UI I can share), and **Morgen** (the calendar that auto-schedules my day). Tick a box in any one of them and the other two catch up in under a minute — priority, due date, scheduled block, completion state, everything. +`task-maxxing` keeps one task in sync across the two apps I actually live in: **Obsidian** (my files) and **Morgen** (the calendar that auto-schedules my day). Tick a box in either one and the other catches up in under a minute — priority, due date, scheduled block, completion state, everything. -This repo is the reference implementation I built for my own vault. It's packaged as a kit you can clone, re-point at your own accounts, and run in about two hours. +This repo is the reference implementation I built for my own vault. It's packaged as a kit you can clone, re-point at your own accounts, and run in about an hour. -### Do you actually need a three-way sync? +### Do you actually need a two-way sync? Honestly? Probably not. For most people, pick one app and live there. -But if you're reading this you probably have ADHD and are trying to stay organized while juggling 43 side projects in 10+ Claude Code instances at the same time — and you refuse to update the same task in three different places like some kind of manual-labor peasant. Same. That's who this is for. +But if you're reading this you probably have ADHD and are trying to stay organized while juggling 43 side projects in 10+ Claude Code instances at the same time — and you refuse to update the same task in two different places like some kind of manual-labor peasant. Same. That's who this is for. -The only paid piece is an **n8n subscription** (which I was paying for anyway for other automation). Everything else — Obsidian, Notion, Morgen's free tier, GitHub, Node — is free or already on your machine. +The only paid piece is an **n8n subscription** (which I was paying for anyway for other automation). Everything else — Obsidian, Morgen's free tier, GitHub, Node — is free or already on your machine. --- ## Why not just... -- **Notion alone** — gorgeous UI, no local files, no auto-scheduler. If Notion goes down, your day goes down. -- **Obsidian alone** — canonical markdown files, no shareable UI, no calendar. -- **Motion / Morgen alone** — auto-scheduling magic, no knowledge graph, no file store. -- **Zapier / Make** — fine for two-way flows, dies the moment you need **three-way** sync with a source-of-truth rule + real conflict resolution. +- **Obsidian alone** — canonical markdown files, no calendar, no auto-scheduler. Tasks just sit there. +- **Motion / Morgen alone** — auto-scheduling magic, no knowledge graph, no file store, no git history. +- **Zapier / Make** — fine for the simple cases, dies the moment you want a real source-of-truth rule + conflict resolution + an echo guard so the system doesn't re-ingest its own writes. - **A single super-app** — doesn't exist. If it did, it'd lock you in and then get bought by Atlassian. -The missing piece is **three-way sync with one canonical source**. `task-maxxing` makes Obsidian canonical (plain markdown, lives in git, still readable in ten years when every SaaS in this list is dead) and treats Notion and Morgen as live mirrors you can edit bidirectionally. +The missing piece is **two-way sync with one canonical source**. `task-maxxing` makes Obsidian canonical (plain markdown, lives in git, still readable in ten years when every SaaS in this list is dead) and treats Morgen as a live mirror you can edit bidirectionally — so you can tick a task off on your phone in the calendar app and it lands back in your vault as a real commit. ### What you actually get -- **Edit anywhere.** Check a task off in Morgen on your phone, it's checked in Notion and Obsidian inside a minute. -- **One source of truth.** Your `.md` files in `06-Tasks/` are canonical. Notion and Morgen are regenerated mirrors — if they drift, the markdown wins. +- **Edit anywhere.** Check a task off in Morgen on your phone, it's checked in Obsidian inside a minute. +- **One source of truth.** Your `.md` files in `06-Tasks/` are canonical. Morgen is a regenerated mirror — if it drifts, the markdown wins. - **Git-backed history.** Every task edit is a git commit. Time-travel, blame, diffs, the works. - **No vendor lock-in.** Turn the whole pipeline off tomorrow and your data is still a folder of markdown files. @@ -84,7 +86,7 @@ The missing piece is **three-way sync with one canonical source**. `task-maxxing ## Architecture -Six directed edges, three sub-workflows, one orchestrator, one local daemon. +Four directed edges, two sub-workflows, one orchestrator, one local daemon. ``` ┌───────────────────────┐ @@ -93,51 +95,46 @@ Six directed edges, three sub-workflows, one orchestrator, one local daemon. │ + .sync-state.json │ └──────────┬────────────┘ │ - ┌────────────────────┼─────────────────────┐ - │ local daemon │ W1 (n8n) │ - │ watches files, │ git push webhook │ - │ git commit, │ ─or─ called by W0 │ - │ git push │ → Notion + Morgen │ - │ │ │ - ▼ ▼ │ - ┌──────────────┐ ┌──────────────┐ │ - │ GitHub │ │ Notion │◄──────────────┤ - │ (task mirror │ │ Tasks DB │ W3 (n8n, │ - │ repo) │ └──────┬───────┘ called by W0) - └──────────────┘ │ Done/Dates │ - │ → Obsidian │ - ▼ │ - ┌──────────────┐ │ - │ Morgen │─────────────────┘ - │ Tasks (inbox│ W2 (n8n, called by W0) - │ list only) │ closed → Obsidian - └──────────────┘ (commit back) - - ┌──────────────────────────────────────────┐ - │ W0 — Sync Orchestrator (every 15 min) │ - │ executeWorkflow W2 → W3 → W1, wait=true │ - │ The ONLY workflow you activate. │ - └──────────────────────────────────────────┘ + ┌────────────────────┼────────────────────┐ + │ local daemon │ W1 (n8n) │ + │ watches files, │ called by W0 │ + │ git commit, │ → Morgen │ + │ git push │ │ + │ ▼ │ + ▼ ┌──────────────┐ │ + ┌──────────────┐ │ Morgen │◄────────────┤ + │ GitHub │ │ Tasks (inbox│ W2 (n8n, │ + │ (task mirror │ │ list only) │ called by │ + │ repo) │ └──────────────┘ W0) │ + └──────────────┘ closed → │ + Obsidian │ + (commit ───┘ + back) + + ┌──────────────────────────────────────┐ + │ W0 — Sync Orchestrator (every 15 m) │ + │ executeWorkflow W2 → W1, wait=true │ + │ The ONLY workflow you activate. │ + └──────────────────────────────────────┘ ``` ### Workflow glossary | Label | Direction | Trigger | What it does | |-------|------------------------------------|-----------------------------|----------------------------------------------------------------------------------| -| **W0**| *meta* | Schedule (every 15 min) | **The orchestrator.** Runs W2 → W3 → W1 in sequence via `executeWorkflow` (wait=true). This is the *only* workflow you activate — it serializes the other three so they never race on `.sync-state.json`. | -| **W1**| Obsidian → Notion + Morgen | Called by W0 (GitHub push trigger present but dormant in default install) | Parses changed `TASKS-*.md` files, creates / updates / archives rows in Notion, creates / updates / closes tasks in Morgen. | -| **W2**| Morgen → Obsidian | Called by W0 | Polls Morgen tasks. On a `closed` task, commits `- [x]` back to the source markdown file. | -| **W3**| Notion → Obsidian | Called by W0 | Polls Notion for rows where Status changed to Done or Due/Scheduled changed. Commits the change back to the source markdown file. | +| **W0**| *meta* | Schedule (every 20 min) | **The orchestrator.** Runs W2 → W1 in sequence via `executeWorkflow` (wait=true). This is the *only* workflow you activate — it serializes the others so they never race on `.sync-state.json`. | +| **W1**| Obsidian → Morgen | Called by W0 (GitHub push trigger present but dormant in default install) | Parses changed `TASKS-*.md` files, creates / updates / closes tasks in Morgen. Mints `🆔 m-XXXXXXXX` IDs in Obsidian for new tasks. | +| **W2**| Morgen → Obsidian | Called by W0 | Polls Morgen tasks. On a `closed` task, commits `- [x]` back to the source markdown file. On new Morgen-origin tasks, appends them to the right `TASKS-{AREA}.md`. | -> **Why an orchestrator?** Three independent 15-min cron triggers race each other and can interleave commits — W1 mid-run clobbers a `.sync-state.json` update from W2, or vice versa. The orchestrator sequences `pull from Morgen → pull from Notion → push merged state to both` so the state file mutates serially. If you really want the un-sequenced version (e.g. you're self-hosting and have your own scheduling), pass `SKIP_ORCHESTRATOR=1` to the installer and leave W1/W2/W3's own triggers active. +> **Why an orchestrator?** Two independent cron triggers can race each other and interleave commits — W1 mid-run clobbers a `.sync-state.json` update from W2, or vice versa. The orchestrator sequences `pull from Morgen → push merged state out` so the state file mutates serially. If you really want the un-sequenced version (e.g. you're self-hosting and have your own scheduling), pass `SKIP_ORCHESTRATOR=1` to the installer and leave W1/W2's own triggers active. > -> **Trade-off: W1 is no longer push-instant.** In the default install, you leave W1 inactive so only W0 can fire it — which means an edit to `06-Tasks/` propagates on the next 15-min tick, not on the next `git push`. If you want instant push→Notion+Morgen, either (a) activate W1 alongside W0 and accept occasional double-fires (n8n handles duplicate work cheaply because the jsCode is diff-aware), or (b) use `SKIP_ORCHESTRATOR=1` and run bare W1/W2/W3 with their own triggers. +> **Trade-off: W1 is no longer push-instant.** In the default install, you leave W1 inactive so only W0 can fire it — which means an edit to `06-Tasks/` propagates on the next 15-min tick, not on the next `git push`. If you want instant push→Morgen, either (a) activate W1 alongside W0 and accept occasional double-fires (n8n handles duplicate work cheaply because the jsCode is diff-aware), or (b) use `SKIP_ORCHESTRATOR=1` and run bare W1/W2 with their own triggers. > -> **Known quirk: W0 self-overlap.** n8n's schedule triggers don't skip-if-running. If a 15-min tick lands while the previous W0 is still executing (possible during a large backfill), the second W0 queues up and starts immediately after — which re-introduces the very race W0 was built to prevent. In practice a full W2+W3+W1 cycle finishes in well under 60 seconds, so the overlap window is tiny. If you see it, bump W0 to every 30 min. +> **Known quirk: W0 self-overlap.** n8n's schedule triggers don't skip-if-running. If a 15-min tick lands while the previous W0 is still executing (possible during a large backfill), the second W0 queues up and starts immediately after — which re-introduces the very race W0 was built to prevent. In practice a full W2+W1 cycle finishes in well under 60 seconds, so the overlap window is tiny. If you see it, bump W0 to every 30 min. ### Daemon (local, macOS) -A small Node process watches `06-Tasks/**/*.md`, debounces edits, and runs `git add && git commit && git push`. The daemon is the **only** part of the system that touches your local filesystem — all three n8n workflows talk to your vault through the GitHub API. This keeps n8n cloud out of your disk and lets W2 / W3 write back to markdown as regular commits. +A small Node process watches `06-Tasks/**/*.md`, debounces edits, and runs `git add && git commit && git push`. The daemon is the **only** part of the system that touches your local filesystem — both n8n workflows talk to your vault through the GitHub API. This keeps n8n cloud out of your disk and lets W2 write back to markdown as regular commits. > *(A note on "daemon" — as a non-technical builder, I get excited seeing the word "daemon" because, in my experience — correct me if I'm wrong — it just means something might happen automatically, behind-the-scenes, or fast. I'm probably a bit wrong in some way, but I **won't** look it up right now because I feel a deep sense of pride in this parenthetical sentence.)* @@ -149,7 +146,6 @@ You'll need accounts (free tiers are fine for all of these except Morgen Pro): - **Obsidian vault** with a `06-Tasks/` folder (any structure — area files named `TASKS-*.md`) - 👉 **Don't have a vault yet?** Use my [**2ndBrain-mogging**](https://github.com/lorecraft-io/2ndBrain-mogging) setup as your starting point. It's the best-of-5 different second-brain systems — I merged the good parts of Karpathy / Jens / eugeniu / AgriciDaniel / NicholasSpisak, cut the dead folders and redundant logic, and shipped what's left. Everything you want, everything you actually need, nothing you don't. `task-maxxing` drops straight into its `06-Tasks/` folder. -- **Notion workspace** + the ability to create an internal integration - **Morgen account** (Pro tier, for API access) - **n8n cloud** account (or self-hosted — you do you) - **GitHub account** with room for one private repo @@ -188,13 +184,13 @@ SCRIPT_PATH="$(pwd)/src/auto-commit.js" \ node scripts/morgen-backfill.js --dry-run # preview node scripts/morgen-backfill.js # live -# 6. Import n8n workflows — W1/W2/W3 + the W0 orchestrator, with IDs -# auto-templated into W0 after W1/W2/W3 are created. -# (DRY_RUN=1 to preview, SKIP_ORCHESTRATOR=1 to import W1/W2/W3 only.) +# 6. Import n8n workflows — W1/W2 + the W0 orchestrator, with IDs +# auto-templated into W0 after W1/W2 are created. +# (DRY_RUN=1 to preview, SKIP_ORCHESTRATOR=1 to import W1/W2 only.) ./scripts/install-workflows.sh # 7. Activate ONLY the W0-Sync-Orchestrator in the n8n UI. -# Leave W1/W2/W3 inactive — W0 calls them directly, in sequence, every 15 min. +# Leave W1/W2 inactive — W0 calls them directly, in sequence, every 20 min. # 8. (Optional) Wire the n8n MCP to Claude Code so you can manage workflows # from the terminal. Your N8N_API_KEY + N8N_BASE_URL are already in .env. @@ -230,12 +226,9 @@ task-maxxing/ │ └── README.md FDA walkthrough + troubleshooting for the daemon ├── workflows/ │ ├── README.md Import-order notes + placeholder reference -│ ├── W0-orchestrator-sync-sequencer.json Sequences W2 → W3 → W1 every 15 min +│ ├── W0-orchestrator-sync-sequencer.json Sequences W2 → W1 every 20 min │ ├── W1-obsidian-git-task-sync.json n8n export (called by W0) │ ├── W2-morgen-task-completion-sync.json n8n export (called by W0) -│ └── W3-notion-done-to-obsidian-sync.json n8n export (called by W0) -├── notion/ -│ └── tasks-db-schema.md Copy-pasteable database schema for Notion ├── scripts/ │ ├── morgen-backfill.js One-time tag/ID backfill │ ├── sync-e2e-tests.js End-to-end smoke tests @@ -256,7 +249,6 @@ task-maxxing/ **Alpha.** Running in production on my vault since early 2026, but it's had exactly one user. Looking for testers who: - live in Obsidian for their PKM -- want Notion as a shareable UI - use Morgen (or want to) as their auto-scheduler - are comfortable running a local daemon and debugging n8n @@ -267,25 +259,20 @@ Open an issue or a discussion if you try it. Bug reports with `.sync-state.json` - **macOS only** for the daemon (launchd). Linux / Windows users need to port it. - **Morgen "inbox" task list only.** Morgen's API doesn't yet expose task-list management, so everything lands in your default inbox list. - **Morgen task-to-calendar promotion is unavailable** via API. You'll still drag tasks onto the calendar in Morgen's UI (or lean on Morgen's auto-scheduler). -- **Rate budget:** W1 is capped at ~100 Notion ops and ~100 Morgen ops per run to stay inside Notion's 3 req/s and Morgen's 300 points / 15 min. Because W0 now fires W2 + W3 + W1 serially on every 15-min tick, plan your ops budget against the *cumulative* per-15-min total (W2 + W3 + W1 combined) — not W1 in isolation. A fresh backfill that touches 300+ tasks will blow past the Morgen budget; pre-stage via `scripts/morgen-backfill.js` instead. -- **Existing users with a `W2-3-1-Sync-Orchestrator`:** the installer creates a new `W0-Sync-Orchestrator` beside your existing one. Delete the old `W2-3-1-Sync-Orchestrator` in the n8n UI before running `scripts/install-workflows.sh`, or you'll end up with two orchestrators firing W1/W2/W3 on independent 15-min cadences — which defeats the whole serialization guarantee. +- **Rate budget:** W1 is capped at ~100 Morgen ops per run to stay inside Morgen's 300 points / 15 min. Because W0 fires W2 + W1 serially on every 20-min tick, plan your ops budget against the *cumulative* per-15-min total (W2 + W1 combined) — not W1 in isolation. A fresh backfill that touches 300+ tasks will blow past the Morgen budget; pre-stage via `scripts/morgen-backfill.js` instead. +- **Existing users with a `W2-3-1-Sync-Orchestrator`:** the installer creates a new `W0-Sync-Orchestrator` beside your existing one. Delete the old `W2-3-1-Sync-Orchestrator` in the n8n UI before running `scripts/install-workflows.sh`, or you'll end up with two orchestrators firing on independent 15-min cadences — which defeats the whole serialization guarantee. --- ## Using these tools outside the sync -None of these are required for the three-way sync — W1/W2/W3 talk to Notion and Morgen with direct API tokens through n8n, no MCPs involved. But if you landed here cold and want to actually *talk* to these tools from Claude Code (add a task from the terminal, query your Notion DB, open your vault), here's the optional add-on layer: +None of these are required for the two-way sync — W1/W2 talk to Morgen with a direct API token through n8n, no MCPs involved. But if you landed here cold and want to actually *talk* to these tools from Claude Code (add a task from the terminal, open your vault, manage workflows), here's the optional add-on layer: - **Morgen MCP** — my unofficial MCP for Morgen. ```bash claude mcp add morgen -- npx -y fidgetcoding-morgen-mcp ``` Lets Claude Code create / update / reflow Morgen tasks and events from the CLI. Repo: [`lorecraft-io/morgen-mcp`](https://github.com/lorecraft-io/morgen-mcp). -- **Notion MCP** — the official Notion MCP server. - ```bash - claude mcp add --transport http notion https://mcp.notion.com/mcp - ``` - Or see [developers.notion.com/docs/mcp](https://developers.notion.com/docs/mcp) for the local-stdio + OAuth variants. - **n8n MCP** — manage the sync workflows from Claude Code after they're installed. Step 8 of the [Quickstart](#quickstart) wires this up with the same `N8N_API_KEY` / `N8N_BASE_URL` the installer already uses: ```bash claude mcp add n8n-mcp \ @@ -308,7 +295,7 @@ This is one of three repos in the stack: |------|-------------| | [`cli-maxxing`](https://github.com/lorecraft-io/cli-maxxing) | Foundation — Claude Code, shell aliases, dev tools, productivity MCPs (Morgen, Motion, n8n, Notion, Playwright, SwiftKit). | | [`creativity-maxxing`](https://github.com/lorecraft-io/creativity-maxxing) | Design skills, video prompt engines, transcription lab, Canva in terminal. | -| **`task-maxxing`** | **This repo** — three-way task sync, Obsidian ↔ Notion ↔ Morgen. | +| **`task-maxxing`** | **This repo** — two-way task sync, Obsidian ↔ Morgen. | Install `cli-maxxing` first (it drops `claude` onto your `PATH`). After that, `creativity-maxxing` and `task-maxxing` can be installed in either order. @@ -322,7 +309,7 @@ MIT — see [LICENSE](LICENSE). ## Credits -Built by **Nate Davidovich** ([lorecraft-io](https://github.com/lorecraft-io)) after a few too many hours wondering why nobody else had shipped a working three-way task sync. This is the reference implementation that powers my personal 2ndBrain vault. +Built by **Nate Davidovich** ([lorecraft-io](https://github.com/lorecraft-io)) after a few too many hours wondering why nobody else had shipped a working two-way task sync between a markdown vault and an auto-scheduling calendar. This is the reference implementation that powers my personal 2ndBrain vault. If you ship a port (Linux daemon, Windows service, Todoist replacement, etc.), open a PR and I'll link it from here. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 289dac1..f565f3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,13 @@ # Architecture +> [!IMPORTANT] +> **2026-05-04 cutover:** This doc was written for the original three-way +> Obsidian ↔ Notion ↔ Morgen pipeline. The kit is now **two-way Obsidian ↔ +> Morgen** — W3 (Notion → Obsidian) is removed and W1 no longer makes +> Notion API calls. Sections describing Notion routing or the W3 worker +> are historical context. Orchestrator graph is now `Every 20m → W2 → W1`. +> See [CHANGELOG.md](../CHANGELOG.md). A full rewrite is queued. + This document explains how task-maxxing actually works. If you just want to get it running, jump to [SETUP.md](SETUP.md). If you want to understand the invariants so you can debug it (or fork it), you're in the right place. diff --git a/docs/DESIGN-RATIONALE.md b/docs/DESIGN-RATIONALE.md index 0f8e104..c5a9239 100644 --- a/docs/DESIGN-RATIONALE.md +++ b/docs/DESIGN-RATIONALE.md @@ -1,5 +1,12 @@ # Design Rationale +> [!IMPORTANT] +> **2026-05-04 cutover:** This doc still describes the original three-way +> design (Obsidian + Notion + Morgen). The kit is now **two-way Obsidian +> ↔ Morgen**. The rationales for the Notion side are kept as historical +> context but no longer drive the implementation. See +> [CHANGELOG.md](../CHANGELOG.md). A full rewrite is queued. + > Why task-maxxing is shaped the way it is. This document is for the person > who's about to fork the repo and is thinking "wait, why did they do it > _that_ way?" It's also for the person (possibly us, six months from now) diff --git a/docs/SETUP.md b/docs/SETUP.md index 59fc2f8..a46606f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,5 +1,12 @@ # Setup +> [!IMPORTANT] +> **2026-05-04 cutover:** Notion was dropped from the kit. Skip any Notion +> integration / DB-schema / workspace-sharing steps in this doc — they're +> retained as historical context but the maintained pipeline is now +> Obsidian ↔ Morgen only. Budget is closer to **1 hour** without the +> Notion side. See [CHANGELOG.md](../CHANGELOG.md). A full rewrite is queued. + This is the zero-to-working walkthrough for task-maxxing. Budget **1–2 hours** end to end. There are 15 steps; none of them are hard, but most of them require a click in a third-party UI, so grab a coffee and take it in order. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5f3fd75..747c134 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,11 @@ # Troubleshooting +> [!IMPORTANT] +> **2026-05-04 cutover:** Notion-side troubleshooting entries (W3 errors, +> Notion 401s, schema-mismatch issues) below are obsolete — Notion is no +> longer in the kit. Skip those entries. The Morgen + Obsidian + W0 / W1 / +> W2 entries are still current. See [CHANGELOG.md](../CHANGELOG.md). + Things that have actually broken during real runs, in rough order of how often they happen. Every entry has a **symptom**, a **diagnostic** command, and a **fix**. diff --git a/examples/sample-.env.example b/examples/sample-.env.example index f1c7806..08c3316 100644 --- a/examples/sample-.env.example +++ b/examples/sample-.env.example @@ -46,16 +46,15 @@ GITHUB_REPO_NAME=your-vault-tasks-repo GITHUB_TOKEN=gho_replace_me # --------------------------------------------------------------------------- -# Notion — database mirror +# Notion — DEPRECATED 2026-05-04 (kit dropped Notion; leave these unset) # --------------------------------------------------------------------------- - -# Internal integration token from https://www.notion.so/my-integrations -# Must start with `ntn_`. The integration must be shared with your tasks DB. -NOTION_TOKEN=ntn_replace_me - -# The Notion database ID (with or without dashes). See notion/tasks-db-schema.md -# for the required schema. -NOTION_DATABASE_ID=00000000-0000-0000-0000-000000000000 +# As of the 2026-05-04 cutover, the kit no longer wires Notion. These vars +# are retained as no-ops for backward-compat with pre-cutover .env files; +# leave them unset on fresh installs. The W3 workflow that consumed them +# has been removed. +# +# NOTION_TOKEN= +# NOTION_DATABASE_ID= # --------------------------------------------------------------------------- # Morgen — native task mirror + calendar scheduling diff --git a/examples/sample-sync-state.json b/examples/sample-sync-state.json index dc843a5..e0dbf3c 100644 --- a/examples/sample-sync-state.json +++ b/examples/sample-sync-state.json @@ -16,7 +16,7 @@ "priority": 2, "due": "2026-04-15", "scheduled": null, - "notionPageId": "11111111-2222-3333-4444-555555555555", + "notionPageId": null, "morgenTaskId": "tsk_0000000000000000000001", "morgenEventId": null, "createdAt": "2026-04-14T15:10:00.000Z", @@ -34,7 +34,7 @@ "priority": 0, "due": null, "scheduled": "2026-04-20", - "notionPageId": "22222222-3333-4444-5555-666666666666", + "notionPageId": null, "morgenTaskId": "tsk_0000000000000000000002", "morgenEventId": null, "createdAt": "2026-04-14T15:11:00.000Z", @@ -52,7 +52,7 @@ "priority": 5, "due": "2026-04-18", "scheduled": "2026-04-16", - "notionPageId": "33333333-4444-5555-6666-777777777777", + "notionPageId": null, "morgenTaskId": "tsk_0000000000000000000003", "morgenEventId": null, "createdAt": "2026-04-14T15:12:00.000Z", @@ -61,4 +61,4 @@ "archived": false } } -} +} \ No newline at end of file diff --git a/notion/tasks-db-schema.md b/notion/tasks-db-schema.md deleted file mode 100644 index 6ff52fb..0000000 --- a/notion/tasks-db-schema.md +++ /dev/null @@ -1,164 +0,0 @@ -# Notion database schema - -The `task-maxxing` pipeline expects a single Notion database with the -property shape below. The exact **names** of the properties matter — the -sync-helpers library and all three workflows reference them literally. If -you rename a property in Notion, you must update `src/sync-helpers.js` -accordingly. - -## Suggested database name - -> **Tasks — Obsidian Sync** - -(Any name works — only the database ID is used at runtime.) - -## Properties - -| Name | Type | Purpose | -|-----------------|--------------|------------------------------------------------------------------------| -| `Task` | title | The task body (cleaned text, priority/date emojis stripped). | -| `Area` | select | Which area file the task lives in. See **Area options** below. | -| `Priority` | select | Obsidian Tasks priority. See **Priority options** below. | -| `Status` | status | One of `Not started`, `In progress`, `Done`. See **Status options**. | -| `Due` | date | Obsidian `📅 YYYY-MM-DD` — bare date only. | -| `Scheduled` | date | Obsidian `⏳ YYYY-MM-DD` — bare date only. | -| `Parent Task` | rich_text | Optional: the hash of the parent task for subtasks. Free-text. | -| `Source File` | rich_text | Relative path inside your 06-Tasks dir (e.g. `TASKS-URGENT.md`). | -| `Hash` | rich_text | 24-char SHA-256 prefix computed by `computeTaskHash()`. Dedup anchor. | -| `Last Synced` | date | ISO-8601 timestamp last touched by any workflow. | - -Every property except `Task` is optional from Notion's perspective — -it's fine to leave a row with a missing `Due` or `Priority`. But each -property **must exist** on the database, because the sync code reads it -by name even when the value is empty. - -## Area options - -`task-maxxing` ships with 12 canonical area keys. The Notion select -options are the number-prefixed labels below — they're intentionally -sorted by the numeric prefix so Notion's select UI orders them naturally. -The `U+00B7` middle-dot (·) separates a parent area from a sub-area. - -| Internal key | Notion select label | -|--------------------------------|-----------------------------------------| -| `URGENT` | `01 URGENT` | -| `GENERAL` | `02 GENERAL` | -| `LORECRAFT` | `03 LORECRAFT` | -| `BLOOM` | `04 BLOOM` | -| `CART-BLANCHE` | `05 CART-BLANCHE` | -| `FIDGETCODING-CONTENT` | `06 FIDGETCODING · content` | -| `FIDGETCODING-MISC-BUILDING` | `07 FIDGETCODING · misc-building` | -| `FUTURE-SCHEDULING` | `08 FUTURE-SCHEDULING` | -| `LAVA-NETWORK` | `09 LAVA-NETWORK` | -| `MMA` | `10 MMA` | -| `PARZVL` | `11 PARZVL` | -| `WAGMI` | `12 WAGMI` | - -**Customizing areas:** these labels are a template — you are expected to -rename them to match your own projects. When you do, update **all** of: - -1. `NOTION_AREAS` in `src/sync-helpers.js` (map internal key → Notion label). -2. `AREA_TO_FILE` in `src/sync-helpers.js` (internal key → relative path). -3. `SAFE_PATH_RE` in `src/sync-helpers.js` (regex allowlist of safe paths). -4. The `Area` select options in Notion (via the UI or API). -5. Any area-specific tag names in your Morgen account, if you've already - created them — or delete them and let the next backfill recreate them. - -The internal keys never appear in Notion; they're just how the JavaScript -refers to each area. You can keep them the same or rename them too. - -## Priority options - -These are the five Obsidian Tasks priority levels, each with its emoji -prefix so the Notion UI shows the same glyph as the markdown source. - -- `🔺 Highest` -- `⏫ High` -- `🔼 Medium` -- `🔽 Low` -- `⏬ Lowest` - -Leave the select empty for tasks with no priority emoji. The mapping -(emoji ↔ integer ↔ Notion label) is centralized in -`src/sync-helpers.js` — search for `PRIORITY_INT_TO_NOTION`. - -## Status options - -Notion's `status` property type comes with three option groups by -default (`To-do`, `In progress`, `Complete`). The pipeline only reads -and writes three specific option **names**: - -- `Not started` -- `In progress` -- `Done` - -If your Notion workspace already has a `status` property with different -labels, rename them to match, or update the string literals in -`src/sync-helpers.js` and the three workflow Code nodes. - -## Creating the database - -### Option A — Notion UI - -1. Create a new full-page database: `/Database > Full page`. -2. Name it **Tasks — Obsidian Sync** (or whatever you prefer). -3. Add each property from the table above. For each, pick the right - **property type** and populate the options (for select/status). -4. Copy the database ID from the share link - (`https://www.notion.so//?v=…`). The ID is the - 32-char hex string before `?v=`; both dashed and undashed forms are - accepted by the API. -5. Set `NOTION_DATABASE_ID=` in your `.env`. - -### Option B — Notion API - -Use the `/v1/databases` create endpoint with a payload like: - -```json -{ - "parent": { "page_id": "" }, - "title": [{ "type": "text", "text": { "content": "Tasks — Obsidian Sync" } }], - "properties": { - "Task": { "title": {} }, - "Area": { "select": { "options": [ - { "name": "01 URGENT" }, - { "name": "02 GENERAL" }, - { "name": "03 LORECRAFT" }, - { "name": "04 BLOOM" }, - { "name": "05 CART-BLANCHE" }, - { "name": "06 FIDGETCODING · content" }, - { "name": "07 FIDGETCODING · misc-building" }, - { "name": "08 FUTURE-SCHEDULING" }, - { "name": "09 LAVA-NETWORK" }, - { "name": "10 MMA" }, - { "name": "11 PARZVL" }, - { "name": "12 WAGMI" } - ]}}, - "Priority": { "select": { "options": [ - { "name": "🔺 Highest" }, - { "name": "⏫ High" }, - { "name": "🔼 Medium" }, - { "name": "🔽 Low" }, - { "name": "⏬ Lowest" } - ]}}, - "Status": { "status": {} }, - "Due": { "date": {} }, - "Scheduled": { "date": {} }, - "Parent Task": { "rich_text": {} }, - "Source File": { "rich_text": {} }, - "Hash": { "rich_text": {} }, - "Last Synced": { "date": {} } - } -} -``` - -Note that Notion's API creates `status` properties with its default -option groups — you may need to rename them via the UI afterward to -match `Not started` / `In progress` / `Done`. - -## Share it with the integration - -Whichever path you took, make sure the database is **shared** with the -internal integration whose token is in `NOTION_TOKEN`. Open the database -in Notion -> `…` menu -> `Add connections` -> pick your integration. -Without this step, the Notion API will return 404 even with a valid ID. diff --git a/scripts/install-workflows.sh b/scripts/install-workflows.sh index 89fc1df..fa26cd3 100755 --- a/scripts/install-workflows.sh +++ b/scripts/install-workflows.sh @@ -1,18 +1,16 @@ #!/usr/bin/env bash # -# install-workflows.sh — import W1/W2/W3 + the W0 sync-orchestrator into an +# install-workflows.sh — import W1/W2 + the W0 sync-orchestrator into an # n8n instance after substituting placeholders with real tokens from env vars. # -# The orchestrator sequences W2 → W3 → W1 every 15 min via executeWorkflow +# The orchestrator sequences W2 → W1 every 20 min via executeWorkflow # (wait=true) so the three sub-workflows never race each other on the shared -# .sync-state.json file. After the script captures real W1/W2/W3 workflow IDs +# .sync-state.json file. After the script captures real W1/W2 workflow IDs # from the n8n API, it templates them into W0 before posting it last. # # Required env vars: -# GITHUB_TOKEN OAuth/PAT used by W1/W3 (fine-grained PAT ok) -# NOTION_TOKEN Notion internal integration token ("ntn_…") +# GITHUB_TOKEN OAuth/PAT used by W1 (fine-grained PAT ok) # MORGEN_KEY Morgen API key (used as "ApiKey ") -# NOTION_DATABASE_ID Target Notion database ID (with or without dashes). # GITHUB_REPO_OWNER The GitHub user/org that owns the task-mirror repo # GITHUB_REPO_NAME The name of the task-mirror repo # N8N_API_KEY Your n8n public API key @@ -25,16 +23,16 @@ # # Optional knobs: # DRY_RUN=1 Render substituted JSON to $TMPDIR, do not POST. -# SKIP_ORCHESTRATOR=1 Only import W1/W2/W3 (advanced — you're wiring -# your own scheduling and accept that W1/W2/W3 can +# SKIP_ORCHESTRATOR=1 Only import W1/W2 (advanced — you're wiring +# your own scheduling and accept that W1/W2 can # race on .sync-state.json). # # After import, activate ONLY the W0-Sync-Orchestrator in the n8n UI. -# Leave W1/W2/W3 inactive — the orchestrator calls them directly. +# Leave W1/W2 inactive — the orchestrator calls them directly. # # ⚠️ Not idempotent. n8n's POST /workflows endpoint creates a NEW workflow # on every call (no upsert by name). Re-running this script doubles your -# workflow set. If you need to re-import, delete the previous W0/W1/W2/W3 +# workflow set. If you need to re-import, delete the previous W0/W1/W2 # in the n8n UI first. set -euo pipefail @@ -61,9 +59,7 @@ cleanup_on_error() { trap cleanup_on_error ERR : "${GITHUB_TOKEN:?GITHUB_TOKEN env var required}" -: "${NOTION_TOKEN:?NOTION_TOKEN env var required}" : "${MORGEN_KEY:?MORGEN_KEY env var required}" -: "${NOTION_DATABASE_ID:?NOTION_DATABASE_ID env var required}" : "${N8N_API_KEY:?N8N_API_KEY env var required}" : "${N8N_BASE_URL:?N8N_BASE_URL env var required (e.g. https://your-tenant.app.n8n.cloud)}" @@ -103,7 +99,6 @@ WF_DIR="${REPO_ROOT}/workflows" WORKFLOWS=( "W1:W1-obsidian-git-task-sync.json" "W2:W2-morgen-task-completion-sync.json" - "W3:W3-notion-done-to-obsidian-sync.json" ) escape_sed() { @@ -111,9 +106,7 @@ escape_sed() { } GH_E="$(escape_sed "${GITHUB_TOKEN}")" -NOTION_E="$(escape_sed "${NOTION_TOKEN}")" MORGEN_E="$(escape_sed "${MORGEN_KEY}")" -NOTION_DB_E="$(escape_sed "${NOTION_DATABASE_ID}")" GH_REPO_E="$(escape_sed "${GITHUB_REPO}")" GH_OWNER_E="$(escape_sed "${GITHUB_OWNER}")" GH_REPO_NAME_E="$(escape_sed "${GITHUB_REPO_NAME}")" @@ -136,9 +129,8 @@ for pair in "${WORKFLOWS[@]}"; do sed \ -e "s|{{GITHUB_TOKEN}}|${GH_E}|g" \ - -e "s|{{NOTION_TOKEN}}|${NOTION_E}|g" \ -e "s|{{MORGEN_KEY}}|${MORGEN_E}|g" \ - -e "s|{{NOTION_DATABASE_ID}}|${NOTION_DB_E}|g" \ + -e "s|{{MORGEN_API_KEY}}|${MORGEN_E}|g" \ -e "s|{{GITHUB_REPO}}|${GH_REPO_E}|g" \ -e "s|{{GITHUB_OWNER}}|${GH_OWNER_E}|g" \ -e "s|{{GITHUB_REPO_NAME}}|${GH_REPO_NAME_E}|g" \ @@ -186,8 +178,8 @@ SKIP_ORCHESTRATOR="${SKIP_ORCHESTRATOR:-0}" # --------------------------------------------------------------------------- # W0 — Sync Orchestrator -# Sequences W2 → W3 → W1 every 15 min via executeWorkflow(wait=true). -# Imported last because it needs the real W1/W2/W3 workflow IDs assigned by +# Sequences W2 → W1 every 20 min via executeWorkflow(wait=true). +# Imported last because it needs the real W1/W2 workflow IDs assigned by # n8n in the loop above (or from a DRY_RUN placeholder). # --------------------------------------------------------------------------- @@ -202,18 +194,15 @@ else W1_ID="${CREATED_IDS[0]}" W2_ID="${CREATED_IDS[1]}" - W3_ID="${CREATED_IDS[2]}" W1_ID_E="$(escape_sed "${W1_ID}")" W2_ID_E="$(escape_sed "${W2_ID}")" - W3_ID_E="$(escape_sed "${W3_ID}")" orch_rendered="$(mktemp -t W0.rendered.XXXXXX).json" : > "${orch_rendered}" sed \ -e "s|{{W1_WORKFLOW_ID}}|${W1_ID_E}|g" \ -e "s|{{W2_WORKFLOW_ID}}|${W2_ID_E}|g" \ - -e "s|{{W3_WORKFLOW_ID}}|${W3_ID_E}|g" \ "${ORCH_SRC}" > "${orch_rendered}" if grep -q '{{[A-Z_]*}}' "${orch_rendered}"; then @@ -274,18 +263,18 @@ for i in "${!CREATED_IDS[@]}"; do done echo if [[ "${SKIP_ORCHESTRATOR}" == "1" ]]; then - echo " NEXT: SKIP_ORCHESTRATOR=1 — you asked for bare W1/W2/W3 with their own" - echo " schedule triggers. Activate them in this order: W1 → W3 → W2" - echo " (W1 is the fast push-based path, W3 guards Notion edits, W2 sweeps Morgen.)" + echo " NEXT: SKIP_ORCHESTRATOR=1 — you asked for bare W1/W2 with their own" + echo " schedule triggers. Activate them in this order: W1 → W2" + echo " (W1 is the fast push-based path, W2 sweeps Morgen.)" else echo " NEXT: activate ONLY the ${ORCH_NAME} in the n8n UI." - echo " Leave W1/W2/W3 inactive — the orchestrator triggers them directly" + echo " Leave W1/W2 inactive — the orchestrator triggers them directly" echo " via executeWorkflow so they never race each other on the shared" echo " .sync-state.json file." fi echo echo " ⚠️ Re-running this script creates DUPLICATE workflows — n8n's API" echo " does not upsert by name. If you need to reinstall, delete the" -echo " existing W0/W1/W2/W3 in the n8n UI (or via DELETE /api/v1/workflows/)" +echo " existing W0/W1/W2 in the n8n UI (or via DELETE /api/v1/workflows/)" echo " before running again, or you will end up with two copies of each." echo "=====================================================================" diff --git a/workflows/README.md b/workflows/README.md index e31c570..c4fa691 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -1,6 +1,11 @@ # Workflows -n8n workflow JSON exports for the three sync paths. Import these into your +> [!NOTE] +> **2026-05-04 cutover:** This is now a two-workflow setup (W2 + W1) +> orchestrated by W0. The W3 (Notion → Obsidian) workflow has been removed +> from the kit. See [`../CHANGELOG.md`](../CHANGELOG.md). + +n8n workflow JSON exports for the two sync paths. Import these into your n8n instance via `../scripts/install-workflows.sh` (preferred) or manually. ## Files diff --git a/workflows/W0-orchestrator-sync-sequencer.json b/workflows/W0-orchestrator-sync-sequencer.json index 5d194aa..c2b2c8c 100644 --- a/workflows/W0-orchestrator-sync-sequencer.json +++ b/workflows/W0-orchestrator-sync-sequencer.json @@ -3,7 +3,7 @@ "nodes": [ { "id": "b7c9a0f1-1a23-4b56-9f8e-1a2b3c4d5e6f", - "name": "Every 15 Minutes", + "name": "Every 20 Minutes", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [ @@ -15,7 +15,7 @@ "interval": [ { "field": "minutes", - "minutesInterval": 15 + "minutesInterval": 20 } ] } @@ -23,7 +23,7 @@ }, { "id": "c0d1e2f3-4a56-4b78-9c0d-1e2f3a4b5c6d", - "name": "Run W2 (Morgen → Obsidian)", + "name": "Run W2 (Morgen \u2192 Obsidian)", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.2, "position": [ @@ -43,31 +43,9 @@ } } }, - { - "id": "d1e2f3a4-5b67-4c89-9d0e-1f2a3b4c5d6e", - "name": "Run W3 (Notion → Obsidian)", - "type": "n8n-nodes-base.executeWorkflow", - "typeVersion": 1.2, - "position": [ - 580, - 300 - ], - "parameters": { - "source": "database", - "workflowId": { - "__rl": true, - "mode": "id", - "value": "{{W3_WORKFLOW_ID}}" - }, - "mode": "each", - "options": { - "waitForSubWorkflow": true - } - } - }, { "id": "e2f3a4b5-6c78-4d90-9e0f-1a2b3c4d5e6f", - "name": "Run W1 (Obsidian → Notion + Morgen)", + "name": "Run W1 (Obsidian \u2192 Morgen)", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.2, "position": [ @@ -89,33 +67,22 @@ } ], "connections": { - "Every 15 Minutes": { - "main": [ - [ - { - "node": "Run W2 (Morgen → Obsidian)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Run W2 (Morgen → Obsidian)": { + "Run W2 (Morgen \u2192 Obsidian)": { "main": [ [ { - "node": "Run W3 (Notion → Obsidian)", + "node": "Run W1 (Obsidian \u2192 Morgen)", "type": "main", "index": 0 } ] ] }, - "Run W3 (Notion → Obsidian)": { + "Every 20 Minutes": { "main": [ [ { - "node": "Run W1 (Obsidian → Notion + Morgen)", + "node": "Run W2 (Morgen \u2192 Obsidian)", "type": "main", "index": 0 } @@ -128,4 +95,4 @@ "availableInMCP": true, "callerPolicy": "workflowsFromSameOwner" } -} +} \ No newline at end of file diff --git a/workflows/W1-obsidian-git-task-sync.json b/workflows/W1-obsidian-git-task-sync.json index 977010f..ca34be4 100644 --- a/workflows/W1-obsidian-git-task-sync.json +++ b/workflows/W1-obsidian-git-task-sync.json @@ -30,7 +30,7 @@ }, { "id": "102e5117-1468-42b8-9699-2b4c437b0c96", - "name": "Parse + Upsert Notion DB", + "name": "Parse + Sync to Morgen", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ @@ -40,7 +40,7 @@ "parameters": { "mode": "runOnceForAllItems", "language": "javaScript", - "jsCode": "const __AUTH_GH = \"Bearer {{GITHUB_TOKEN}}\";\nconst __AUTH_NOTION = \"Bearer {{NOTION_TOKEN}}\";\nconst __AUTH_MORGEN = \"ApiKey {{MORGEN_KEY}}\";\nconst authedRequest = async (credType, options) => {\n const headers = Object.assign({}, options.headers || {});\n if (credType === 'githubApi') {\n headers['Authorization'] = __AUTH_GH;\n if (!headers['Accept']) headers['Accept'] = 'application/vnd.github+json';\n } else if (credType === 'notionApi') {\n headers['Authorization'] = __AUTH_NOTION;\n if (!headers['Notion-Version']) headers['Notion-Version'] = '2022-06-28';\n if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';\n } else if (credType === 'httpHeaderAuth') {\n headers['Authorization'] = __AUTH_MORGEN;\n } else {\n throw new Error('Unknown credType: ' + credType);\n }\n return await this.helpers.httpRequest(Object.assign({}, options, { headers }));\n};\nconst PRIORITY_EMOJI_TO_INT = Object.freeze({\n '\ud83d\udd3a': 1, '\u23eb': 2, '\ud83d\udd3c': 5, '\ud83d\udd3d': 7, '\u23ec': 9,\n});\nconst PRIORITY_INT_TO_EMOJI = Object.freeze({\n 1: '\ud83d\udd3a', 2: '\u23eb', 5: '\ud83d\udd3c', 7: '\ud83d\udd3d', 9: '\u23ec',\n});\nconst PRIORITY_INT_TO_NOTION = Object.freeze({\n 1: '\ud83d\udd3a Highest', 2: '\u23eb High', 5: '\ud83d\udd3c Medium', 7: '\ud83d\udd3d Low', 9: '\u23ec Lowest',\n});\nconst PRIORITY_NOTION_TO_INT = Object.freeze({\n '\ud83d\udd3a Highest': 1, '\u23eb High': 2, '\ud83d\udd3c Medium': 5, '\ud83d\udd3d Low': 7, '\u23ec Lowest': 9,\n});\nfunction parseObsidianPriority(emoji) {\n if (emoji == null) return 0;\n const k = String(emoji);\n return Object.prototype.hasOwnProperty.call(PRIORITY_EMOJI_TO_INT, k)\n ? PRIORITY_EMOJI_TO_INT[k] : 0;\n}\nfunction morgenPriorityToObsidian(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return '';\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_EMOJI, n)\n ? PRIORITY_INT_TO_EMOJI[n] : '';\n}\nfunction morgenPriorityToNotion(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return null;\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_NOTION, n)\n ? PRIORITY_INT_TO_NOTION[n] : null;\n}\nfunction notionPriorityToInt(label) {\n if (label == null) return 0;\n const k = String(label);\n return Object.prototype.hasOwnProperty.call(PRIORITY_NOTION_TO_INT, k)\n ? PRIORITY_NOTION_TO_INT[k] : 0;\n}\nconst NOTION_AREAS = Object.freeze({\n URGENT: '01 URGENT',\n GENERAL: '02 GENERAL',\n LORECRAFT: '03 LORECRAFT',\n BLOOM: '04 BLOOM',\n 'CART-BLANCHE': '05 CART-BLANCHE',\n 'FIDGETCODING-CONTENT': '06 FIDGETCODING \u00b7 content',\n 'FIDGETCODING-MISC-BUILDING': '07 FIDGETCODING \u00b7 misc-building',\n 'FUTURE-SCHEDULING': '08 FUTURE-SCHEDULING',\n 'LAVA-NETWORK': '09 LAVA-NETWORK',\n MMA: '10 MMA',\n PARZVL: '11 PARZVL',\n WAGMI: '12 WAGMI',\n});\nconst NOTION_AREA_TO_KEY = Object.freeze(\n Object.fromEntries(Object.entries(NOTION_AREAS).map(([k, v]) => [v, k]))\n);\nconst AREA_TO_FILE = Object.freeze({\n URGENT: 'TASKS-URGENT.md',\n GENERAL: 'TASKS-GENERAL.md',\n LORECRAFT: 'TASKS-LORECRAFT.md',\n BLOOM: 'TASKS-BLOOM.md',\n 'CART-BLANCHE': 'TASKS-CART-BLANCHE.md',\n 'FIDGETCODING-CONTENT': 'FIDGETCODING/content/TASKS-FIDGETCODING-content.md',\n 'FIDGETCODING-MISC-BUILDING': 'FIDGETCODING/misc-building/TASKS-FIDGETCODING-misc-building.md',\n 'FUTURE-SCHEDULING': 'FUTURE-SCHEDULING/TASKS-FUTURE-SCHEDULING.md',\n 'LAVA-NETWORK': 'TASKS-LAVA-NETWORK.md',\n MMA: 'TASKS-MMA.md',\n PARZVL: 'TASKS-PARZVL.md',\n WAGMI: 'TASKS-WAGMI.md',\n});\nfunction parseArea(sourceFilePath) {\n if (sourceFilePath == null) return 'GENERAL';\n const raw = String(sourceFilePath);\n if (!raw) return 'GENERAL';\n const p = raw.replace(/\\\\/g, '/').replace(/^\\.\\//, '').replace(/^06-Tasks\\//, '');\n if (/(^|\\/)FIDGETCODING\\/content\\//.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FIDGETCODING\\/misc-building\\//.test(p)) return 'FIDGETCODING-MISC-BUILDING';\n if (/(^|\\/)FIDGETCODING\\/TASKS-FIDGETCODING\\.md$/.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FUTURE-SCHEDULING\\//.test(p)) return 'FUTURE-SCHEDULING';\n const seg = p.split('/').pop() || '';\n const m = seg.match(/^TASKS-([A-Za-z0-9][A-Za-z0-9_-]*)\\.md$/i);\n if (m) {\n const key = m[1].toUpperCase();\n if (Object.prototype.hasOwnProperty.call(AREA_TO_FILE, key)) return key;\n }\n return 'GENERAL';\n}\nfunction areaKeyToNotionLabel(key) {\n return NOTION_AREAS[key] || NOTION_AREAS.GENERAL;\n}\nfunction notionLabelToAreaKey(label) {\n if (label == null) return 'GENERAL';\n return NOTION_AREA_TO_KEY[label] || 'GENERAL';\n}\nfunction areaKeyToFile(key) {\n return AREA_TO_FILE[key] || AREA_TO_FILE.GENERAL;\n}\nconst MORGEN_AREAS = Object.freeze({\n URGENT: 'Urgent',\n GENERAL: 'General',\n LORECRAFT: 'Lorecraft',\n BLOOM: 'Bloom',\n 'CART-BLANCHE': 'Cart-Blanche',\n 'FIDGETCODING-CONTENT': 'Fidgetcoding-Content',\n 'FIDGETCODING-MISC-BUILDING': 'Fidgetcoding-Building',\n 'FUTURE-SCHEDULING': 'Future-Scheduling',\n 'LAVA-NETWORK': 'Lava-Network',\n MMA: 'MMA',\n PARZVL: 'Parzvl',\n WAGMI: 'WAGMI',\n});\nfunction areaKeyToMorgenLabel(key) {\n return MORGEN_AREAS[key] || MORGEN_AREAS.GENERAL;\n}\nfunction getDesiredMorgenTagLabels(task) {\n const labels = new Set();\n labels.add(areaKeyToMorgenLabel(task && task.area));\n const p = task && task.priority;\n if (p === 1 || p === 2) labels.add(MORGEN_AREAS.URGENT);\n return Array.from(labels).sort();\n}\nfunction sameTagLabelSet(a, b) {\n if (!Array.isArray(a) || !Array.isArray(b)) return false;\n if (a.length !== b.length) return false;\n const sa = a.slice().sort();\n const sb = b.slice().sort();\n for (let i = 0; i < sa.length; i++) if (sa[i] !== sb[i]) return false;\n return true;\n}\nconst SAFE_PATH_RE = /^(TASKS-(URGENT|GENERAL|LORECRAFT|BLOOM|CART-BLANCHE|LAVA-NETWORK|MMA|PARZVL|WAGMI)\\.md|FIDGETCODING\\/(content|misc-building)\\/TASKS-FIDGETCODING-(content|misc-building)\\.md|FIDGETCODING\\/TASKS-FIDGETCODING\\.md|FUTURE-SCHEDULING\\/TASKS-FUTURE-SCHEDULING\\.md)$/;\nfunction isSafePath(p) {\n if (typeof p !== 'string') return false;\n if (p.includes('..') || p.includes('\\\\') || p.startsWith('/')) return false;\n const normalized = p.replace(/^06-Tasks\\//, '');\n return SAFE_PATH_RE.test(normalized);\n}\nfunction computeTaskHash(input) {\n const i = input || {};\n const parts = [\n i.sourceFile == null ? '' : String(i.sourceFile),\n i.text == null ? '' : String(i.text),\n i.priority == null ? '0' : String(parseInt(i.priority, 10) || 0),\n i.due == null ? '' : String(i.due).slice(0, 10),\n i.scheduled == null ? '' : String(i.scheduled).slice(0, 10),\n ];\n return crypto.createHash('sha256').update(parts.join('::'), 'utf8').digest('hex').slice(0, 24);\n}\nfunction computeLineHash(rawLine) {\n const s = rawLine == null ? '' : String(rawLine).replace(/\\s+$/, '');\n return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);\n}\nconst TASK_LINE_RE = /^(\\s*)([-*+])\\s+\\[([ xX/\\-!?*])\\]\\s+(.*)$/;\nconst DATE_RE = /(\\d{4}-\\d{2}-\\d{2})/;\nconst FENCE_RE = /^(\\s*)(```|~~~)/;\nconst PRIO_EMOJIS = ['\ud83d\udd3a', '\u23eb', '\ud83d\udd3c', '\ud83d\udd3d', '\u23ec'];\nfunction extractDate(text, emoji) {\n if (!text) return null;\n const idx = text.indexOf(emoji);\n if (idx === -1) return null;\n const tail = text.slice(idx + emoji.length, idx + emoji.length + 32);\n const m = tail.match(DATE_RE);\n return m ? m[1] : null;\n}\nfunction extractPriorityEmoji(text) {\n if (!text) return '';\n for (const e of PRIO_EMOJIS) {\n if (text.indexOf(e) !== -1) return e;\n }\n return '';\n}\nfunction extractRecurrence(text) {\n if (!text) return null;\n const idx = text.indexOf('\ud83d\udd01');\n if (idx === -1) return null;\n const tail = text.slice(idx + '\ud83d\udd01'.length);\n const stopRe = /[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94]/;\n const si = tail.search(stopRe);\n const rule = (si === -1 ? tail : tail.slice(0, si)).trim();\n return rule || null;\n}\nfunction stripTaskMetadata(text) {\n if (!text) return '';\n let out = String(text);\n out = out.replace(/[\ud83d\udd3a\u23eb\ud83d\udd3c\ud83d\udd3d\u23ec]/g, ' ');\n out = out.replace(/[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705]\\s*\\d{4}-\\d{2}-\\d{2}/g, ' ');\n out = out.replace(/\ud83d\udd01[^\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94\\n]*/g, ' ');\n out = out.replace(/\ud83c\udd94\\s*[A-Za-z0-9_-]+/g, ' ');\n return out.replace(/\\s+/g, ' ').trim();\n}\nfunction extractMorgenId(text) {\n if (!text) return null;\n const idx = text.indexOf('\ud83c\udd94');\n if (idx === -1) return null;\n const tail = text.slice(idx + '\ud83c\udd94'.length);\n const m = tail.match(/^\\s*(m-[0-9a-f]{8})\\b/);\n return m ? m[1] : null;\n}\nfunction generateMorgenId() {\n return 'm-' + crypto.randomBytes(4).toString('hex');\n}\nfunction insertMorgenId(rawLine, newId) {\n if (rawLine == null) return '';\n if (newId == null || newId === '') return String(rawLine);\n const raw = String(rawLine);\n if (raw.indexOf('\ud83c\udd94') !== -1) return raw;\n const m = raw.match(TASK_LINE_RE);\n if (!m) return raw;\n const indent = m[1] || '';\n const bullet = m[2] || '-';\n const statusChar = m[3];\n const body = m[4] || '';\n const prefix = indent + bullet + ' [' + statusChar + '] ';\n const token = '\ud83c\udd94 ' + String(newId);\n const anchors = ['\u2705', '\ud83d\udcc5', '\u23f3', '\ud83d\udeeb', '\ud83d\udd01'];\n let insertAt = -1;\n for (const a of anchors) {\n const idx = body.indexOf(a);\n if (idx !== -1 && (insertAt === -1 || idx < insertAt)) insertAt = idx;\n }\n let newBody;\n if (insertAt === -1) {\n newBody = body.replace(/\\s+$/, '') + ' ' + token;\n } else {\n const head = body.slice(0, insertAt).replace(/\\s+$/, '');\n const tail = body.slice(insertAt);\n newBody = head + ' ' + token + ' ' + tail;\n }\n newBody = newBody.replace(/\\s+/g, ' ').replace(/^\\s+/, '');\n return prefix + newBody;\n}\nfunction parseObsidianTasks(markdown, sourceFilePath) {\n const out = [];\n if (markdown == null) return out;\n let text;\n try { text = String(markdown); } catch (_) { return out; }\n text = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n const lines = text.split('\\n');\n const area = parseArea(sourceFilePath);\n const sourceFile = sourceFilePath == null ? '' : String(sourceFilePath).replace(/^06-Tasks\\//, '');\n let inFence = false;\n let fenceMarker = null;\n for (let i = 0; i < lines.length; i++) {\n const rawLine = lines[i];\n const fm = rawLine.match(FENCE_RE);\n if (fm) {\n if (!inFence) { inFence = true; fenceMarker = fm[2]; }\n else if (rawLine.trim().startsWith(fenceMarker)) { inFence = false; fenceMarker = null; }\n continue;\n }\n if (inFence) continue;\n let m;\n try { m = rawLine.match(TASK_LINE_RE); } catch (_) { m = null; }\n if (!m) continue;\n const statusChar = m[3];\n const body = m[4] || '';\n const done = statusChar === 'x' || statusChar === 'X';\n const priorityEmoji = extractPriorityEmoji(body);\n const priority = parseObsidianPriority(priorityEmoji);\n const due = extractDate(body, '\ud83d\udcc5');\n const scheduled = extractDate(body, '\u23f3');\n const start = extractDate(body, '\ud83d\udeeb');\n const doneDate = extractDate(body, '\u2705');\n const recurrence = extractRecurrence(body);\n const morgenId = extractMorgenId(body);\n const cleanText = stripTaskMetadata(body);\n const task = {\n text: cleanText,\n priority,\n due: due || null,\n scheduled: scheduled || null,\n start: start || null,\n done,\n doneDate: doneDate || null,\n recurrence,\n morgenId: morgenId || null,\n lineNo: i + 1,\n rawLine,\n area,\n sourceFile,\n };\n task.hash = computeTaskHash(task);\n out.push(task);\n }\n return out;\n}\nconst SYNC_STATE_VERSION = 2;\nfunction emptySyncState() {\n return { _version: SYNC_STATE_VERSION, _tagCache: {}, entries: {} };\n}\nfunction loadSyncState(rawJsonString) {\n if (rawJsonString == null || rawJsonString === '') return emptySyncState();\n let parsed;\n try { parsed = JSON.parse(String(rawJsonString)); } catch (_) { return emptySyncState(); }\n if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return emptySyncState();\n const s = emptySyncState();\n const loadedVersion = typeof parsed._version === 'number' ? parsed._version : 0;\n const needsV2Migration = loadedVersion < 2;\n s._version = SYNC_STATE_VERSION;\n if (!needsV2Migration && parsed._tagCache && typeof parsed._tagCache === 'object' && !Array.isArray(parsed._tagCache)) {\n s._tagCache = Object.assign({}, parsed._tagCache);\n }\n if (parsed.entries && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {\n s.entries = {};\n for (const k of Object.keys(parsed.entries)) {\n const v = parsed.entries[k];\n if (v && typeof v === 'object') {\n const copy = Object.assign({}, v);\n if (needsV2Migration) delete copy.morgenTagLabels;\n s.entries[k] = copy;\n }\n }\n }\n return s;\n}\nfunction serializeSyncState(state) {\n const safe = state && typeof state === 'object' ? state : emptySyncState();\n return JSON.stringify({\n _version: typeof safe._version === 'number' ? safe._version : SYNC_STATE_VERSION,\n _tagCache: safe._tagCache && typeof safe._tagCache === 'object' ? safe._tagCache : {},\n entries: safe.entries && typeof safe.entries === 'object' ? safe.entries : {},\n }, null, 2) + '\\n';\n}\nfunction upsertMappingEntry(state, hash, patch) {\n const base = state && typeof state === 'object' ? state : emptySyncState();\n const nextEntries = Object.assign({}, base.entries || {});\n const existing = nextEntries[hash] || {};\n const nowIso = new Date().toISOString();\n nextEntries[hash] = Object.assign(\n { hash, notionPageId: null, morgenTaskId: null, morgenEventId: null,\n createdAt: existing.createdAt || nowIso, archived: false },\n existing,\n patch || {},\n { hash, updatedAt: nowIso, lastSyncedAt: nowIso },\n );\n return {\n _version: typeof base._version === 'number' ? base._version : SYNC_STATE_VERSION,\n _tagCache: Object.assign({}, base._tagCache || {}),\n entries: nextEntries,\n };\n}\nfunction findByNotionId(state, notionPageId) {\n if (!state || !state.entries || notionPageId == null) return null;\n const target = String(notionPageId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && e.notionPageId && String(e.notionPageId) === target) return { hash, entry: e };\n }\n return null;\n}\nfunction findByMorgenId(state, morgenTaskId) {\n if (!state || !state.entries || morgenTaskId == null) return null;\n const target = String(morgenTaskId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && ((e.morgenTaskId && String(e.morgenTaskId) === target) ||\n (e.morgenEventId && String(e.morgenEventId) === target))) {\n return { hash, entry: e };\n }\n }\n return null;\n}\nfunction reconstructObsidianLine(task, existingLine) {\n const t = task || {};\n let indent = '';\n let bullet = '-';\n let statusChar = t.done ? 'x' : ' ';\n if (typeof existingLine === 'string' && existingLine.length > 0) {\n const m = existingLine.match(TASK_LINE_RE);\n if (m) {\n indent = m[1] || '';\n bullet = m[2] || '-';\n if (t.done === undefined) {\n statusChar = (m[3] === 'x' || m[3] === 'X') ? 'x' : ' ';\n }\n }\n }\n const tokens = [];\n if (t.text != null) tokens.push(String(t.text).trim());\n const prioEmoji = morgenPriorityToObsidian(t.priority);\n if (prioEmoji) tokens.push(prioEmoji);\n if (t.due) tokens.push('\ud83d\udcc5 ' + String(t.due).slice(0, 10));\n if (t.scheduled) tokens.push('\u23f3 ' + String(t.scheduled).slice(0, 10));\n if (t.start) tokens.push('\ud83d\udeeb ' + String(t.start).slice(0, 10));\n if (t.recurrence) tokens.push('\ud83d\udd01 ' + t.recurrence);\n if (t.done && t.doneDate) tokens.push('\u2705 ' + String(t.doneDate).slice(0, 10));\n return indent + bullet + ' [' + statusChar + '] ' + tokens.join(' ').replace(/\\s+/g, ' ').trim();\n}\nfunction flipTaskDone(line) {\n if (line == null) return '';\n const raw = String(line);\n const m = raw.match(TASK_LINE_RE);\n if (!m) return raw;\n const indent = m[1] || '';\n const bullet = m[2] || '-';\n const body = m[4] || '';\n const today = new Date().toISOString().slice(0, 10);\n let newBody = body;\n if (newBody.indexOf('\u2705') === -1) {\n newBody = newBody.replace(/\\s+$/, '') + ' \u2705 ' + today;\n }\n return indent + bullet + ' [x] ' + newBody.trim();\n}\nfunction dateToMorgenLocal(dateStr) {\n if (dateStr == null || dateStr === '') return null;\n const s = String(dateStr).trim();\n if (!s) return null;\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s + 'T09:00:00';\n const m = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n if (m) return m[1] + 'T' + m[2];\n const m2 = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2})$/);\n if (m2) return m2[1] + 'T' + m2[2] + ':00';\n return null;\n}\nconst BOT_COMMIT_PREFIXES = Object.freeze(['[bot:W1]', '[bot:W2]', '[bot:W3]', '[bot:backfill]']);\nfunction isBotCommitMessage(msg) {\n if (msg == null) return false;\n const s = String(msg);\n return BOT_COMMIT_PREFIXES.some(p => s.startsWith(p));\n}\nconst crypto = require('crypto');\nconst REPO = '{{GITHUB_OWNER}}/{{GITHUB_REPO}}';\nconst NOTION_DB_ID = '{{NOTION_DATABASE_ID}}';\nconst RATE_BUDGET = 250;\nconst nowIso = new Date().toISOString();\ntry {\n const pushBody = ($input.first() && $input.first().json && $input.first().json.body) || {};\n const headCommitMsg = (pushBody.head_commit && pushBody.head_commit.message) || '';\n if (headCommitMsg.startsWith('[bot:')) {\n return [{ json: { ok: true, skipped: 'bot-loop-prevention', commit: headCommitMsg.slice(0, 80) } }];\n }\n const tree = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/git/trees/main?recursive=1',\n json: true,\n });\n const taskFiles = (tree.tree || []).filter(f =>\n f && f.type === 'blob' && f.path && f.path.endsWith('.md') &&\n /(^|\\/)TASKS-/.test(f.path) && !f.path.endsWith('/TASKS.md') && f.path !== 'TASKS.md'\n );\n const fileContents = await Promise.all(taskFiles.map(async f => {\n const resp = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + f.path + '?ref=main',\n json: true,\n });\n return { path: f.path, content: Buffer.from(resp.content, 'base64').toString('utf-8').replace(/\\r\\n/g, '\\n'), sha: resp.sha };\n }));\n const allTasks = [];\n for (const f of fileContents) {\n allTasks.push(...parseObsidianTasks(f.content, f.path));\n }\n const openTasks = allTasks.filter(t => !t.done);\n const doneTasks = allTasks.filter(t => t.done);\n let syncState = emptySyncState();\n let syncStateSha = null;\n try {\n const stateResp = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json?ref=main',\n json: true,\n });\n const raw = Buffer.from(stateResp.content, 'base64').toString('utf-8');\n syncState = loadSyncState(raw);\n syncStateSha = stateResp.sha;\n } catch (e) {\n const msg = String((e && e.message) || '');\n const isNotFound = msg.includes('404') || (e && (e.httpCode === '404' || e.statusCode === 404));\n if (!isNotFound) throw e;\n }\n const currentHashByMorgenId = new Map();\n for (const t of allTasks) {\n if (t.morgenId) currentHashByMorgenId.set(t.morgenId, t.hash);\n }\n if (syncState.entries && Object.keys(syncState.entries).length > 0) {\n const rekeyed = {};\n for (const [oldHash, entry] of Object.entries(syncState.entries)) {\n if (!entry) continue;\n const mid = entry.morgenId;\n if (mid && currentHashByMorgenId.has(mid)) {\n const newHash = currentHashByMorgenId.get(mid);\n const merged = Object.assign({}, rekeyed[newHash] || {}, entry, { hash: newHash });\n if (rekeyed[newHash] && rekeyed[newHash].morgenTaskId && !merged.morgenTaskId) {\n merged.morgenTaskId = rekeyed[newHash].morgenTaskId;\n }\n if (rekeyed[newHash] && rekeyed[newHash].notionPageId && !merged.notionPageId) {\n merged.notionPageId = rekeyed[newHash].notionPageId;\n }\n rekeyed[newHash] = merged;\n } else {\n if (!rekeyed[oldHash]) rekeyed[oldHash] = entry;\n }\n }\n syncState.entries = rekeyed;\n }\n const existing = [];\n let cursor = null;\n do {\n const queryBody = { page_size: 100 };\n if (cursor) queryBody.start_cursor = cursor;\n const resp = await authedRequest('notionApi', {\n method: 'POST',\n url: 'https://api.notion.com/v1/databases/' + NOTION_DB_ID + '/query',\n body: queryBody,\n json: true,\n });\n existing.push(...(resp.results || []));\n cursor = resp.has_more ? resp.next_cursor : null;\n } while (cursor);\n const existingByHash = new Map();\n for (const page of existing) {\n const h = page && page.properties && page.properties.Hash && page.properties.Hash.rich_text && page.properties.Hash.rich_text[0] && page.properties.Hash.rich_text[0].plain_text;\n if (h) existingByHash.set(h, page.id);\n }\n const parsedHashes = new Set(openTasks.map(t => t.hash));\n const toCreate = openTasks.filter(t => !existingByHash.has(t.hash));\n const toArchive = [];\n for (const [h, pageId] of existingByHash.entries()) {\n if (!parsedHashes.has(h)) toArchive.push(pageId);\n }\n const createdPageIds = new Map();\n const BATCH_SIZE = 3;\n const BATCH_DELAY_MS = 250;\n for (let bi = 0; bi < toCreate.length; bi += BATCH_SIZE) {\n const batch = toCreate.slice(bi, bi + BATCH_SIZE);\n await Promise.all(batch.map(async (task) => {\n const areaLabel = areaKeyToNotionLabel(task.area);\n const properties = {\n 'Task': { title: [{ text: { content: (task.text || '').slice(0, 1900) } }] },\n 'Area': { select: { name: areaLabel } },\n 'Status': { status: { name: 'Not started' } },\n 'Source File': { rich_text: [{ text: { content: task.sourceFile || '' } }] },\n 'Hash': { rich_text: [{ text: { content: task.hash } }] },\n 'Last Synced': { date: { start: nowIso } },\n };\n const prioLabel = morgenPriorityToNotion(task.priority);\n if (prioLabel) properties['Priority'] = { select: { name: prioLabel } };\n if (task.due) properties['Due'] = { date: { start: task.due } };\n if (task.scheduled) properties['Scheduled'] = { date: { start: task.scheduled } };\n try {\n const resp = await authedRequest('notionApi', {\n method: 'POST',\n url: 'https://api.notion.com/v1/pages',\n body: { parent: { database_id: NOTION_DB_ID }, properties },\n json: true,\n });\n if (resp && resp.id) {\n createdPageIds.set(task.hash, resp.id);\n existingByHash.set(task.hash, resp.id);\n }\n } catch (e) {\n }\n }));\n if (bi + BATCH_SIZE < toCreate.length) await new Promise(r => setTimeout(r, BATCH_DELAY_MS));\n }\n for (let bi = 0; bi < toArchive.length; bi += BATCH_SIZE) {\n const batch = toArchive.slice(bi, bi + BATCH_SIZE);\n await Promise.all(batch.map(async (pageId) => {\n try {\n await authedRequest('notionApi', {\n method: 'PATCH',\n url: 'https://api.notion.com/v1/pages/' + pageId,\n body: { archived: true },\n json: true,\n });\n } catch (e) {}\n }));\n if (bi + BATCH_SIZE < toArchive.length) await new Promise(r => setTimeout(r, BATCH_DELAY_MS));\n }\n for (const task of openTasks) {\n const mappedPageId = existingByHash.get(task.hash);\n if (!mappedPageId) continue;\n if (syncState.entries && syncState.entries[task.hash]) {\n if (syncState.entries[task.hash].notionPageId !== mappedPageId) {\n syncState.entries[task.hash].notionPageId = mappedPageId;\n syncState.entries[task.hash].updatedAt = nowIso;\n }\n } else {\n syncState = upsertMappingEntry(syncState, task.hash, {\n notionPageId: mappedPageId,\n sourceFile: task.sourceFile,\n lineNo: task.lineNo,\n text: task.text,\n area: task.area,\n priority: task.priority,\n due: task.due,\n scheduled: task.scheduled,\n lineHash: computeLineHash(task.rawLine),\n });\n }\n }\n const mappedByHash = new Map();\n for (const [h, entry] of Object.entries(syncState.entries || {})) {\n mappedByHash.set(h, entry);\n }\n const morgenCreate = [];\n const morgenClose = [];\n const dirtyFiles = new Map();\n const existingIdSet = new Set();\n for (const t of openTasks) if (t.morgenId) existingIdSet.add(t.morgenId);\n for (const t of doneTasks) if (t.morgenId) existingIdSet.add(t.morgenId);\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenId) existingIdSet.add(e.morgenId);\n }\n function mintUniqueId() {\n for (let attempt = 0; attempt < 10; attempt++) {\n const id = generateMorgenId();\n if (!existingIdSet.has(id)) { existingIdSet.add(id); return id; }\n }\n throw new Error('mintUniqueId: 10 collisions');\n }\n const mappedByMorgenId = new Map();\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenId) mappedByMorgenId.set(e.morgenId, { key: h, entry: e });\n }\n for (const task of openTasks) {\n if (task.morgenId) continue;\n const newId = mintUniqueId();\n const file = fileContents.find(function (f) { return f.path === task.sourceFile; });\n if (!file) continue;\n if (!isSafePath(task.sourceFile)) continue;\n const existingDirty = dirtyFiles.get(task.sourceFile);\n const baseContent = existingDirty ? existingDirty.newContent : file.content;\n const updatedLine = insertMorgenId(task.rawLine, newId);\n const idx = baseContent.indexOf(task.rawLine);\n if (idx === -1) continue;\n const newContent = baseContent.slice(0, idx) + updatedLine + baseContent.slice(idx + task.rawLine.length);\n dirtyFiles.set(task.sourceFile, {\n newContent: newContent,\n originalSha: existingDirty ? existingDirty.originalSha : file.sha,\n count: (existingDirty ? existingDirty.count : 0) + 1,\n });\n task.morgenId = newId;\n task.rawLine = updatedLine;\n }\n const morgenUpdate = [];\n for (const task of openTasks) {\n if (!task.morgenId) continue;\n const byId = mappedByMorgenId.get(task.morgenId);\n if (byId && byId.entry.morgenTaskId && !byId.entry.archived) {\n const e = byId.entry;\n const desiredLabels = getDesiredMorgenTagLabels(task);\n const currentLabels = Array.isArray(e.morgenTagLabels) ? e.morgenTagLabels : null;\n const tagsChanged = !currentLabels || !sameTagLabelSet(currentLabels, desiredLabels);\n const changed =\n (e.text !== task.text) ||\n (e.priority !== task.priority) ||\n ((e.due || null) !== (task.due || null)) ||\n tagsChanged;\n if (changed) morgenUpdate.push({ task, entry: e, desiredLabels, tagsChanged });\n continue;\n }\n const mapped = mappedByHash.get(task.hash);\n if (mapped && mapped.morgenTaskId && !mapped.archived) continue;\n morgenCreate.push(task);\n }\n for (const task of doneTasks) {\n if (task.morgenId) {\n const byId = mappedByMorgenId.get(task.morgenId);\n if (byId && byId.entry.morgenTaskId && !byId.entry.archived) {\n morgenClose.push({ entry: byId.entry, oldHash: byId.key });\n continue;\n }\n }\n for (const [h, entry] of mappedByHash) {\n if (entry.sourceFile === task.sourceFile && entry.text === task.text && entry.morgenTaskId && !entry.archived) {\n morgenClose.push({ entry, oldHash: h });\n break;\n }\n }\n }\n const neededAreaLabels = new Set();\n for (const t of morgenCreate) {\n for (const label of getDesiredMorgenTagLabels(t)) neededAreaLabels.add(label);\n }\n for (const u of morgenUpdate) {\n if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n for (const label of u.desiredLabels) neededAreaLabels.add(label);\n }\n }\n let uncachedTagCount = 0;\n for (const label of neededAreaLabels) {\n if (!syncState._tagCache[label]) uncachedTagCount++;\n }\n const morgenCallCount = morgenCreate.length + morgenClose.length + morgenUpdate.length;\n const willCallMorgen = morgenCallCount > 0 || uncachedTagCount > 0;\n const projectedPoints = (willCallMorgen ? 10 : 0) + uncachedTagCount + morgenCallCount;\n if (projectedPoints > RATE_BUDGET) {\n throw new Error('ABORT [#rate-budget] projected ' + projectedPoints + 'pts > ' + RATE_BUDGET);\n }\n const morgenErrors = [];\n if (willCallMorgen) {\n const tagsResp = await authedRequest('httpHeaderAuth', {\n method: 'GET',\n url: 'https://api.morgen.so/v3/tags/list',\n json: true,\n });\n let tags = [];\n if (Array.isArray(tagsResp)) tags = tagsResp;\n else if (tagsResp && Array.isArray(tagsResp.tags)) tags = tagsResp.tags;\n else if (tagsResp && tagsResp.data && Array.isArray(tagsResp.data.tags)) tags = tagsResp.data.tags;\n else if (tagsResp && Array.isArray(tagsResp.data)) tags = tagsResp.data;\n for (const tag of tags) {\n if (tag && tag.name && tag.id) syncState._tagCache[tag.name] = tag.id;\n }\n for (const label of neededAreaLabels) {\n if (!syncState._tagCache[label]) {\n try {\n const resp = await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tags/create',\n body: { name: label },\n json: true,\n });\n const newId =\n (resp && typeof resp.id === 'string' && resp.id) ||\n (resp && resp.data && typeof resp.data.id === 'string' && resp.data.id) ||\n (resp && resp.data && resp.data.tag && typeof resp.data.tag.id === 'string' && resp.data.tag.id) ||\n null;\n if (newId) {\n syncState._tagCache[label] = newId;\n } else {\n morgenErrors.push('tag-create ' + label + ': unexpected response shape');\n }\n } catch (e) {\n morgenErrors.push('tag-create ' + label + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n }\n for (const task of morgenCreate) {\n const desiredLabels = getDesiredMorgenTagLabels(task);\n const tagIds = [];\n for (const label of desiredLabels) {\n const id = syncState._tagCache[label];\n if (id) tagIds.push(id);\n }\n const body = {\n title: (task.text || '').slice(0, 500),\n description: 'From Obsidian: ' + task.sourceFile,\n priority: task.priority,\n taskListId: 'inbox',\n tags: tagIds,\n };\n if (task.due) body.due = dateToMorgenLocal(task.due);\n try {\n const resp = await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tasks/create',\n body,\n json: true,\n });\n const createdMorgenTaskId = resp && resp.data && resp.data.id;\n if (createdMorgenTaskId) {\n syncState = upsertMappingEntry(syncState, task.hash, {\n notionPageId: createdPageIds.get(task.hash) || existingByHash.get(task.hash) || null,\n morgenTaskId: createdMorgenTaskId,\n morgenId: task.morgenId || null,\n sourceFile: task.sourceFile,\n lineNo: task.lineNo,\n text: task.text,\n area: task.area,\n priority: task.priority,\n due: task.due,\n scheduled: task.scheduled,\n lineHash: computeLineHash(task.rawLine),\n morgenTagLabels: desiredLabels,\n });\n }\n } catch (e) {\n morgenErrors.push('task-create ' + task.hash + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n for (const u of morgenUpdate) {\n const updBody = { id: u.entry.morgenTaskId };\n if (u.entry.text !== u.task.text) updBody.title = (u.task.text || '').slice(0, 500);\n if (u.entry.priority !== u.task.priority) updBody.priority = u.task.priority;\n if ((u.entry.due || null) !== (u.task.due || null)) {\n updBody.due = u.task.due ? dateToMorgenLocal(u.task.due) : null;\n }\n if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n const tagIds = [];\n for (const label of u.desiredLabels) {\n const id = syncState._tagCache[label];\n if (id) tagIds.push(id);\n }\n updBody.tags = tagIds;\n }\n try {\n await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tasks/update',\n body: updBody,\n json: true,\n });\n const k = (function () {\n for (const h of Object.keys(syncState.entries || {})) {\n if (syncState.entries[h] && syncState.entries[h].morgenId === u.task.morgenId) return h;\n }\n return null;\n })();\n if (k) {\n syncState.entries[k].text = u.task.text;\n syncState.entries[k].priority = u.task.priority;\n syncState.entries[k].due = u.task.due;\n syncState.entries[k].scheduled = u.task.scheduled;\n syncState.entries[k].updatedAt = new Date().toISOString();\n syncState.entries[k].lineHash = computeLineHash(u.task.rawLine);\n if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n syncState.entries[k].morgenTagLabels = u.desiredLabels;\n }\n }\n } catch (e) {\n morgenErrors.push('task-update ' + u.task.morgenId + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n for (const c of morgenClose) {\n try {\n await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tasks/close',\n body: { id: c.entry.morgenTaskId },\n json: true,\n });\n syncState = upsertMappingEntry(syncState, c.oldHash, { archived: true });\n } catch (e) {\n morgenErrors.push('task-close ' + c.oldHash + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n }\n for (const [hash, pageId] of createdPageIds.entries()) {\n if (syncState.entries[hash]) {\n syncState.entries[hash].notionPageId = pageId;\n syncState.entries[hash].updatedAt = nowIso;\n } else {\n syncState = upsertMappingEntry(syncState, hash, { notionPageId: pageId });\n }\n }\n const writeBackErrors = [];\n for (const [wbPath, info] of dirtyFiles) {\n if (!isSafePath(wbPath)) { writeBackErrors.push('unsafe-path ' + wbPath); continue; }\n const encodedMd = Buffer.from(info.newContent, 'utf-8').toString('base64');\n const msgMd = '[bot:W1-assign-id] inject ' + info.count + ' \ud83c\udd94 into ' + wbPath;\n const pb = { message: msgMd, content: encodedMd, branch: 'main', sha: info.originalSha };\n try {\n await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath,\n body: pb,\n json: true,\n });\n } catch (e) {\n const status = e && (e.statusCode || (e.response && e.response.statusCode));\n if (status === 409) {\n try {\n const fresh = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath + '?ref=main',\n json: true,\n });\n pb.sha = fresh.sha;\n await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath,\n body: pb,\n json: true,\n });\n } catch (e2) {\n writeBackErrors.push(wbPath + ' 409-retry: ' + String(e2 && e2.message || e2).slice(0, 80));\n }\n } else {\n writeBackErrors.push(wbPath + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n await new Promise(function (r) { setTimeout(r, 1100); });\n }\n const stateJson = serializeSyncState(syncState);\n const encoded = Buffer.from(stateJson, 'utf-8').toString('base64');\n const commitMsg = '[bot:W1] sync-state: ' + toCreate.length + '+notion, ' + toArchive.length + '-notion, ' + morgenCreate.length + '+morgen, ' + morgenClose.length + '-morgen';\n const putBody = { message: commitMsg, content: encoded, branch: 'main' };\n if (syncStateSha) putBody.sha = syncStateSha;\n async function putWithRetry() {\n try {\n return await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json',\n body: putBody,\n json: true,\n });\n } catch (e) {\n const status = e.statusCode || (e.response && e.response.statusCode);\n if (status !== 409) throw e;\n const fresh = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json?ref=main',\n json: true,\n });\n putBody.sha = fresh.sha;\n return await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json',\n body: putBody,\n json: true,\n });\n }\n }\n await putWithRetry();\n return [{\n json: {\n ok: true,\n timestamp: nowIso,\n files_processed: fileContents.length,\n parsed_open: openTasks.length,\n parsed_done: doneTasks.length,\n notion_created: toCreate.length,\n notion_archived: toArchive.length,\n morgen_created: morgenCreate.length,\n morgen_closed: morgenClose.length,\n morgen_updated: morgenUpdate.length,\n morgen_points: projectedPoints,\n morgen_errors: morgenErrors.slice(0, 10),\n commit_message: commitMsg,\n }\n }];\n} catch (e) {\n const safe = { message: String((e && e.message) || e), name: (e && e.name) || 'Error' };\n return [{ json: { error: safe, failed: true, timestamp: new Date().toISOString() } }];\n}\n\n\n" + "jsCode": "// ============================================================================\n// W1 \u2014 Parse + Sync to Morgen (formerly \"Parse + Upsert Notion DB\")\n//\n// CUTOVER 2026-05-04: Notion has been dropped from the task-sync stack.\n// This worker now syncs Obsidian \u2192 Morgen ONLY. All Notion call sites\n// (DB query loop, page create, page archive) have been removed. The\n// `__AUTH_NOTION` constant and the `'notionApi'` branch of authedRequest\n// are gone. The `notionPageId` field is preserved in sync-state entries\n// for backward-compatibility with old entries (set to `null` on update;\n// not deleted) but no code path reads or writes a Notion page.\n//\n// Inputs preserved:\n// - File parsing (parseObsidianTasks, fenced-block aware)\n// - Sync-state load/save (`.sync-state.json`, schema v2, _tagCache)\n// - m-ID minting + injection (\"\ud83c\udd94 m-XXXXXXXX\")\n// - Morgen create / update / close\n// - GitHub file write-back (idempotent, 409-retry)\n//\n// Inputs removed:\n// - Notion DB do-while query loop\n// - toCreate/toArchive Notion calls (createPage, archivePage)\n// - createdPageIds map (no longer needed)\n// - notionPageId WRITES (left as `null` on new entries; existing\n// non-null values are preserved untouched)\n// - `__AUTH_NOTION` constant\n// - `'notionApi'` branch of authedRequest (returns explicit error if\n// anything ever calls it)\n//\n// Commit prefix: still `[bot:W1]` so the orchestrator's loop-prevention\n// check (`headCommitMsg.startsWith('[bot:')`) still skips our own commits.\n// ============================================================================\nconst __AUTH_GH = \"Bearer {{GITHUB_TOKEN}}\";\nconst __AUTH_MORGEN = \"ApiKey {{MORGEN_API_KEY}}\";\nconst authedRequest = async (credType, options) => {\n const headers = Object.assign({}, options.headers || {});\n if (credType === 'githubApi') {\n headers['Authorization'] = __AUTH_GH;\n if (!headers['Accept']) headers['Accept'] = 'application/vnd.github+json';\n } else if (credType === 'httpHeaderAuth') {\n headers['Authorization'] = __AUTH_MORGEN;\n } else if (credType === 'notionApi') {\n // 2026-05-04: Notion is dropped. Surface a loud error if any code path\n // accidentally reaches this branch (none in this file does \u2014 kept as\n // a tripwire only).\n throw new Error('notionApi credType called after 2026-05-04 cutover \u2014 Notion is no longer wired');\n } else {\n throw new Error('Unknown credType: ' + credType);\n }\n return await this.helpers.httpRequest(Object.assign({}, options, { headers }));\n};\nconst PRIORITY_EMOJI_TO_INT = Object.freeze({\n '\ud83d\udd3a': 1, '\u23eb': 2, '\ud83d\udd3c': 5, '\ud83d\udd3d': 7, '\u23ec': 9,\n});\nconst PRIORITY_INT_TO_EMOJI = Object.freeze({\n 1: '\ud83d\udd3a', 2: '\u23eb', 5: '\ud83d\udd3c', 7: '\ud83d\udd3d', 9: '\u23ec',\n});\nfunction parseObsidianPriority(emoji) {\n if (emoji == null) return 0;\n const k = String(emoji);\n return Object.prototype.hasOwnProperty.call(PRIORITY_EMOJI_TO_INT, k)\n ? PRIORITY_EMOJI_TO_INT[k] : 0;\n}\nfunction morgenPriorityToObsidian(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return '';\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_EMOJI, n)\n ? PRIORITY_INT_TO_EMOJI[n] : '';\n}\n// Internal area key \u2192 file path. The \"Notion-prefixed\" Morgen tag labels\n// (e.g. \"09 LAVA-NETWORK\") are still produced by W2's Direction-D and read\n// by maketasks SKILL.md; they are kept here for tag-label parity with W2.\n// They are no longer used to write to a Notion DB \u2014 they're now just the\n// tag-label scheme W2's `notionLabelToAreaKey` recognizes.\nconst NOTION_AREAS = Object.freeze({\n URGENT: '01 URGENT',\n GENERAL: '02 GENERAL',\n LORECRAFT: '03 LORECRAFT',\n BLOOM: '04 BLOOM',\n 'CART-BLANCHE': '05 CART-BLANCHE',\n 'FIDGETCODING-CONTENT': '06 FIDGETCODING \u00b7 content',\n 'FIDGETCODING-MISC-BUILDING': '07 FIDGETCODING \u00b7 misc-building',\n 'FUTURE-SCHEDULING': '08 FUTURE-SCHEDULING',\n 'LAVA-NETWORK': '09 LAVA-NETWORK',\n MMA: '10 MMA',\n PARZVL: '11 PARZVL',\n WAGMI: '12 WAGMI',\n});\nconst NOTION_AREA_TO_KEY = Object.freeze(\n Object.fromEntries(Object.entries(NOTION_AREAS).map(([k, v]) => [v, k]))\n);\nconst AREA_TO_FILE = Object.freeze({\n URGENT: 'TASKS-URGENT.md',\n GENERAL: 'TASKS-GENERAL.md',\n LORECRAFT: 'TASKS-LORECRAFT.md',\n BLOOM: 'TASKS-BLOOM.md',\n 'CART-BLANCHE': 'TASKS-CART-BLANCHE.md',\n 'FIDGETCODING-CONTENT': 'FIDGETCODING/content/TASKS-FIDGETCODING-content.md',\n 'FIDGETCODING-MISC-BUILDING': 'FIDGETCODING/misc-building/TASKS-FIDGETCODING-misc-building.md',\n 'FUTURE-SCHEDULING': 'FUTURE-SCHEDULING/TASKS-FUTURE-SCHEDULING.md',\n 'LAVA-NETWORK': 'TASKS-LAVA-NETWORK.md',\n MMA: 'TASKS-MMA.md',\n PARZVL: 'TASKS-PARZVL.md',\n WAGMI: 'TASKS-WAGMI.md',\n});\nfunction parseArea(sourceFilePath) {\n if (sourceFilePath == null) return 'GENERAL';\n const raw = String(sourceFilePath);\n if (!raw) return 'GENERAL';\n const p = raw.replace(/\\\\/g, '/').replace(/^\\.\\//, '').replace(/^08-Tasks\\//, '');\n if (/(^|\\/)FIDGETCODING\\/content\\//.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FIDGETCODING\\/misc-building\\//.test(p)) return 'FIDGETCODING-MISC-BUILDING';\n if (/(^|\\/)FIDGETCODING\\/TASKS-FIDGETCODING\\.md$/.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FUTURE-SCHEDULING\\//.test(p)) return 'FUTURE-SCHEDULING';\n const seg = p.split('/').pop() || '';\n const m = seg.match(/^TASKS-([A-Za-z0-9][A-Za-z0-9_-]*)\\.md$/i);\n if (m) {\n const key = m[1].toUpperCase();\n if (Object.prototype.hasOwnProperty.call(AREA_TO_FILE, key)) return key;\n }\n return 'GENERAL';\n}\nfunction areaKeyToFile(key) {\n return AREA_TO_FILE[key] || AREA_TO_FILE.GENERAL;\n}\nconst MORGEN_AREAS = Object.freeze({\n URGENT: 'Urgent',\n GENERAL: 'General',\n LORECRAFT: 'Lorecraft',\n BLOOM: 'Bloom',\n 'CART-BLANCHE': 'Cart-Blanche',\n 'FIDGETCODING-CONTENT': 'Fidgetcoding-Content',\n 'FIDGETCODING-MISC-BUILDING': 'Fidgetcoding-Building',\n 'FUTURE-SCHEDULING': 'Future-Scheduling',\n 'LAVA-NETWORK': 'Lava-Network',\n MMA: 'MMA',\n PARZVL: 'Parzvl',\n WAGMI: 'WAGMI',\n});\nfunction areaKeyToMorgenLabel(key) {\n return MORGEN_AREAS[key] || MORGEN_AREAS.GENERAL;\n}\nfunction getDesiredMorgenTagLabels(task) {\n const labels = new Set();\n labels.add(areaKeyToMorgenLabel(task && task.area));\n const p = task && task.priority;\n if (p === 1 || p === 2) labels.add(MORGEN_AREAS.URGENT);\n return Array.from(labels).sort();\n}\nfunction sameTagLabelSet(a, b) {\n if (!Array.isArray(a) || !Array.isArray(b)) return false;\n if (a.length !== b.length) return false;\n const sa = a.slice().sort();\n const sb = b.slice().sort();\n for (let i = 0; i < sa.length; i++) if (sa[i] !== sb[i]) return false;\n return true;\n}\nconst SAFE_PATH_RE = /^(TASKS-(URGENT|GENERAL|LORECRAFT|BLOOM|CART-BLANCHE|LAVA-NETWORK|MMA|PARZVL|WAGMI)\\.md|FIDGETCODING\\/(content|misc-building)\\/TASKS-FIDGETCODING-(content|misc-building)\\.md|FIDGETCODING\\/TASKS-FIDGETCODING\\.md|FUTURE-SCHEDULING\\/TASKS-FUTURE-SCHEDULING\\.md)$/;\nfunction isSafePath(p) {\n if (typeof p !== 'string') return false;\n if (p.includes('..') || p.includes('\\\\') || p.startsWith('/')) return false;\n const normalized = p.replace(/^08-Tasks\\//, '');\n return SAFE_PATH_RE.test(normalized);\n}\nfunction computeTaskHash(input) {\n const i = input || {};\n const parts = [\n i.sourceFile == null ? '' : String(i.sourceFile),\n i.text == null ? '' : String(i.text),\n i.priority == null ? '0' : String(parseInt(i.priority, 10) || 0),\n i.due == null ? '' : String(i.due).slice(0, 10),\n i.scheduled == null ? '' : String(i.scheduled).slice(0, 10),\n ];\n return crypto.createHash('sha256').update(parts.join('::'), 'utf8').digest('hex').slice(0, 24);\n}\nfunction computeLineHash(rawLine) {\n const s = rawLine == null ? '' : String(rawLine).replace(/\\s+$/, '');\n return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);\n}\nconst TASK_LINE_RE = /^(\\s*)([-*+])\\s+\\[([ xX/\\-!?*])\\]\\s+(.*)$/;\nconst DATE_RE = /(\\d{4}-\\d{2}-\\d{2})/;\nconst FENCE_RE = /^(\\s*)(```|~~~)/;\nconst PRIO_EMOJIS = ['\ud83d\udd3a', '\u23eb', '\ud83d\udd3c', '\ud83d\udd3d', '\u23ec'];\nfunction extractDate(text, emoji) {\n if (!text) return null;\n const idx = text.indexOf(emoji);\n if (idx === -1) return null;\n const tail = text.slice(idx + emoji.length, idx + emoji.length + 32);\n const m = tail.match(DATE_RE);\n return m ? m[1] : null;\n}\nfunction extractPriorityEmoji(text) {\n if (!text) return '';\n for (const e of PRIO_EMOJIS) {\n if (text.indexOf(e) !== -1) return e;\n }\n return '';\n}\nfunction extractRecurrence(text) {\n if (!text) return null;\n const idx = text.indexOf('\ud83d\udd01');\n if (idx === -1) return null;\n const tail = text.slice(idx + '\ud83d\udd01'.length);\n const stopRe = /[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94]/;\n const si = tail.search(stopRe);\n const rule = (si === -1 ? tail : tail.slice(0, si)).trim();\n return rule || null;\n}\nfunction stripTaskMetadata(text) {\n if (!text) return '';\n let out = String(text);\n out = out.replace(/[\ud83d\udd3a\u23eb\ud83d\udd3c\ud83d\udd3d\u23ec]/g, ' ');\n out = out.replace(/[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705]\\s*\\d{4}-\\d{2}-\\d{2}/g, ' ');\n out = out.replace(/\ud83d\udd01[^\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94\\n]*/g, ' ');\n out = out.replace(/\ud83c\udd94\\s*[A-Za-z0-9_-]+/g, ' ');\n return out.replace(/\\s+/g, ' ').trim();\n}\nfunction extractMorgenId(text) {\n if (!text) return null;\n const idx = text.indexOf('\ud83c\udd94');\n if (idx === -1) return null;\n const tail = text.slice(idx + '\ud83c\udd94'.length);\n const m = tail.match(/^\\s*(m-[0-9a-f]{8})\\b/);\n return m ? m[1] : null;\n}\nfunction generateMorgenId() {\n return 'm-' + crypto.randomBytes(4).toString('hex');\n}\nfunction insertMorgenId(rawLine, newId) {\n if (rawLine == null) return '';\n if (newId == null || newId === '') return String(rawLine);\n const raw = String(rawLine);\n if (raw.indexOf('\ud83c\udd94') !== -1) return raw;\n const m = raw.match(TASK_LINE_RE);\n if (!m) return raw;\n const indent = m[1] || '';\n const bullet = m[2] || '-';\n const statusChar = m[3];\n const body = m[4] || '';\n const prefix = indent + bullet + ' [' + statusChar + '] ';\n const token = '\ud83c\udd94 ' + String(newId);\n const anchors = ['\u2705', '\ud83d\udcc5', '\u23f3', '\ud83d\udeeb', '\ud83d\udd01'];\n let insertAt = -1;\n for (const a of anchors) {\n const idx = body.indexOf(a);\n if (idx !== -1 && (insertAt === -1 || idx < insertAt)) insertAt = idx;\n }\n let newBody;\n if (insertAt === -1) {\n newBody = body.replace(/\\s+$/, '') + ' ' + token;\n } else {\n const head = body.slice(0, insertAt).replace(/\\s+$/, '');\n const tail = body.slice(insertAt);\n newBody = head + ' ' + token + ' ' + tail;\n }\n newBody = newBody.replace(/\\s+/g, ' ').replace(/^\\s+/, '');\n return prefix + newBody;\n}\nfunction parseObsidianTasks(markdown, sourceFilePath) {\n const out = [];\n if (markdown == null) return out;\n let text;\n try { text = String(markdown); } catch (_) { return out; }\n text = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n const lines = text.split('\\n');\n const area = parseArea(sourceFilePath);\n const sourceFile = sourceFilePath == null ? '' : String(sourceFilePath).replace(/^08-Tasks\\//, '');\n let inFence = false;\n let fenceMarker = null;\n for (let i = 0; i < lines.length; i++) {\n const rawLine = lines[i];\n const fm = rawLine.match(FENCE_RE);\n if (fm) {\n if (!inFence) { inFence = true; fenceMarker = fm[2]; }\n else if (rawLine.trim().startsWith(fenceMarker)) { inFence = false; fenceMarker = null; }\n continue;\n }\n if (inFence) continue;\n let m;\n try { m = rawLine.match(TASK_LINE_RE); } catch (_) { m = null; }\n if (!m) continue;\n const statusChar = m[3];\n const body = m[4] || '';\n const done = statusChar === 'x' || statusChar === 'X';\n const priorityEmoji = extractPriorityEmoji(body);\n const priority = parseObsidianPriority(priorityEmoji);\n const due = extractDate(body, '\ud83d\udcc5');\n const scheduled = extractDate(body, '\u23f3');\n const start = extractDate(body, '\ud83d\udeeb');\n const doneDate = extractDate(body, '\u2705');\n const recurrence = extractRecurrence(body);\n const morgenId = extractMorgenId(body);\n const cleanText = stripTaskMetadata(body);\n const task = {\n text: cleanText,\n priority,\n due: due || null,\n scheduled: scheduled || null,\n start: start || null,\n done,\n doneDate: doneDate || null,\n recurrence,\n morgenId: morgenId || null,\n lineNo: i + 1,\n rawLine,\n area,\n sourceFile,\n };\n task.hash = computeTaskHash(task);\n out.push(task);\n }\n return out;\n}\nconst SYNC_STATE_VERSION = 2;\nfunction emptySyncState() {\n return { _version: SYNC_STATE_VERSION, _tagCache: {}, entries: {} };\n}\nfunction loadSyncState(rawJsonString) {\n if (rawJsonString == null || rawJsonString === '') return emptySyncState();\n let parsed;\n try { parsed = JSON.parse(String(rawJsonString)); } catch (_) { return emptySyncState(); }\n if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return emptySyncState();\n const s = emptySyncState();\n const loadedVersion = typeof parsed._version === 'number' ? parsed._version : 0;\n // Migration v1 \u2192 v2: the first v4 push had a tag-response-parser bug that\n // left morgenTagLabels populated even though Morgen actually got tags:[].\n // Strip the local belief so the next run re-diffs every task and pushes\n // the correct tag set. Also wipe _tagCache since the v1 cache only held\n // old numbered labels anyway.\n const needsV2Migration = loadedVersion < 2;\n s._version = SYNC_STATE_VERSION;\n if (!needsV2Migration && parsed._tagCache && typeof parsed._tagCache === 'object' && !Array.isArray(parsed._tagCache)) {\n s._tagCache = Object.assign({}, parsed._tagCache);\n }\n if (parsed.entries && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {\n s.entries = {};\n for (const k of Object.keys(parsed.entries)) {\n const v = parsed.entries[k];\n if (v && typeof v === 'object') {\n const copy = Object.assign({}, v);\n if (needsV2Migration) delete copy.morgenTagLabels;\n s.entries[k] = copy;\n }\n }\n }\n return s;\n}\nfunction serializeSyncState(state) {\n const safe = state && typeof state === 'object' ? state : emptySyncState();\n return JSON.stringify({\n _version: typeof safe._version === 'number' ? safe._version : SYNC_STATE_VERSION,\n _tagCache: safe._tagCache && typeof safe._tagCache === 'object' ? safe._tagCache : {},\n entries: safe.entries && typeof safe.entries === 'object' ? safe.entries : {},\n }, null, 2) + '\\n';\n}\nfunction upsertMappingEntry(state, hash, patch) {\n const base = state && typeof state === 'object' ? state : emptySyncState();\n const nextEntries = Object.assign({}, base.entries || {});\n const existing = nextEntries[hash] || {};\n const nowIso = new Date().toISOString();\n // notionPageId is preserved as a field for backward-compat with older\n // entries (see SYNC-STATE-FORMAT.md). Post-cutover (2026-05-04) W1 never\n // sets it to a non-null value; existing non-null values are inherited\n // from `existing` via Object.assign and left untouched.\n nextEntries[hash] = Object.assign(\n { hash, notionPageId: null, morgenTaskId: null, morgenEventId: null,\n createdAt: existing.createdAt || nowIso, archived: false },\n existing,\n patch || {},\n { hash, updatedAt: nowIso, lastSyncedAt: nowIso },\n );\n return {\n _version: typeof base._version === 'number' ? base._version : SYNC_STATE_VERSION,\n _tagCache: Object.assign({}, base._tagCache || {}),\n entries: nextEntries,\n };\n}\nfunction findByMorgenId(state, morgenTaskId) {\n if (!state || !state.entries || morgenTaskId == null) return null;\n const target = String(morgenTaskId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && ((e.morgenTaskId && String(e.morgenTaskId) === target) ||\n (e.morgenEventId && String(e.morgenEventId) === target))) {\n return { hash, entry: e };\n }\n }\n return null;\n}\nfunction reconstructObsidianLine(task, existingLine) {\n const t = task || {};\n let indent = '';\n let bullet = '-';\n let statusChar = t.done ? 'x' : ' ';\n if (typeof existingLine === 'string' && existingLine.length > 0) {\n const m = existingLine.match(TASK_LINE_RE);\n if (m) {\n indent = m[1] || '';\n bullet = m[2] || '-';\n if (t.done === undefined) {\n statusChar = (m[3] === 'x' || m[3] === 'X') ? 'x' : ' ';\n }\n }\n }\n const tokens = [];\n if (t.text != null) tokens.push(String(t.text).trim());\n const prioEmoji = morgenPriorityToObsidian(t.priority);\n if (prioEmoji) tokens.push(prioEmoji);\n if (t.due) tokens.push('\ud83d\udcc5 ' + String(t.due).slice(0, 10));\n if (t.scheduled) tokens.push('\u23f3 ' + String(t.scheduled).slice(0, 10));\n if (t.start) tokens.push('\ud83d\udeeb ' + String(t.start).slice(0, 10));\n if (t.recurrence) tokens.push('\ud83d\udd01 ' + t.recurrence);\n if (t.done && t.doneDate) tokens.push('\u2705 ' + String(t.doneDate).slice(0, 10));\n return indent + bullet + ' [' + statusChar + '] ' + tokens.join(' ').replace(/\\s+/g, ' ').trim();\n}\nfunction flipTaskDone(line) {\n if (line == null) return '';\n const raw = String(line);\n const m = raw.match(TASK_LINE_RE);\n if (!m) return raw;\n const indent = m[1] || '';\n const bullet = m[2] || '-';\n const body = m[4] || '';\n const today = new Date().toISOString().slice(0, 10);\n let newBody = body;\n if (newBody.indexOf('\u2705') === -1) {\n newBody = newBody.replace(/\\s+$/, '') + ' \u2705 ' + today;\n }\n return indent + bullet + ' [x] ' + newBody.trim();\n}\nfunction dateToMorgenLocal(dateStr) {\n if (dateStr == null || dateStr === '') return null;\n const s = String(dateStr).trim();\n if (!s) return null;\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s + 'T09:00:00';\n const m = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n if (m) return m[1] + 'T' + m[2];\n const m2 = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2})$/);\n if (m2) return m2[1] + 'T' + m2[2] + ':00';\n return null;\n}\nconst BOT_COMMIT_PREFIXES = Object.freeze(['[bot:W1]', '[bot:W2]', '[bot:W3]', '[bot:backfill]']);\nfunction isBotCommitMessage(msg) {\n if (msg == null) return false;\n const s = String(msg);\n return BOT_COMMIT_PREFIXES.some(p => s.startsWith(p));\n}\nconst crypto = require('crypto');\nconst REPO = 'lorecraft-io/obsidian-tasks-sync';\nconst RATE_BUDGET = 250;\nconst nowIso = new Date().toISOString();\ntry {\n const pushBody = ($input.first() && $input.first().json && $input.first().json.body) || {};\n const headCommitMsg = (pushBody.head_commit && pushBody.head_commit.message) || '';\n if (headCommitMsg.startsWith('[bot:')) {\n return [{ json: { ok: true, skipped: 'bot-loop-prevention', commit: headCommitMsg.slice(0, 80) } }];\n }\n const tree = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/git/trees/main?recursive=1',\n json: true,\n });\n const taskFiles = (tree.tree || []).filter(f =>\n f && f.type === 'blob' && f.path && f.path.endsWith('.md') &&\n /(^|\\/)TASKS-/.test(f.path) && !f.path.endsWith('/TASKS.md') && f.path !== 'TASKS.md'\n );\n const fileContents = await Promise.all(taskFiles.map(async f => {\n const resp = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + f.path + '?ref=main',\n json: true,\n });\n return { path: f.path, content: Buffer.from(resp.content, 'base64').toString('utf-8').replace(/\\r\\n/g, '\\n'), sha: resp.sha };\n }));\n const allTasks = [];\n for (const f of fileContents) {\n allTasks.push(...parseObsidianTasks(f.content, f.path));\n }\n const openTasks = allTasks.filter(t => !t.done);\n const doneTasks = allTasks.filter(t => t.done);\n let syncState = emptySyncState();\n let syncStateSha = null;\n try {\n const stateResp = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json?ref=main',\n json: true,\n });\n const raw = Buffer.from(stateResp.content, 'base64').toString('utf-8');\n syncState = loadSyncState(raw);\n syncStateSha = stateResp.sha;\n } catch (e) {\n const msg = String((e && e.message) || '');\n const isNotFound = msg.includes('404') || (e && (e.httpCode === '404' || e.statusCode === 404));\n if (!isNotFound) throw e;\n }\n const currentHashByMorgenId = new Map();\n for (const t of allTasks) {\n if (t.morgenId) currentHashByMorgenId.set(t.morgenId, t.hash);\n }\n if (syncState.entries && Object.keys(syncState.entries).length > 0) {\n const rekeyed = {};\n for (const [oldHash, entry] of Object.entries(syncState.entries)) {\n if (!entry) continue;\n const mid = entry.morgenId;\n if (mid && currentHashByMorgenId.has(mid)) {\n const newHash = currentHashByMorgenId.get(mid);\n const merged = Object.assign({}, rekeyed[newHash] || {}, entry, { hash: newHash });\n if (rekeyed[newHash] && rekeyed[newHash].morgenTaskId && !merged.morgenTaskId) {\n merged.morgenTaskId = rekeyed[newHash].morgenTaskId;\n }\n // Preserve any pre-existing notionPageId on the merged entry so old\n // archived rows stay traceable. New entries created below will have\n // notionPageId: null (set by upsertMappingEntry's defaults).\n if (rekeyed[newHash] && rekeyed[newHash].notionPageId && !merged.notionPageId) {\n merged.notionPageId = rekeyed[newHash].notionPageId;\n }\n rekeyed[newHash] = merged;\n } else {\n if (!rekeyed[oldHash]) rekeyed[oldHash] = entry;\n }\n }\n syncState.entries = rekeyed;\n }\n // ==========================================================================\n // 2026-05-04 cutover: removed Notion DB query loop and Notion create/archive\n // batch loops. Mapping by Notion page ID is no longer maintained.\n // ==========================================================================\n const mappedByHash = new Map();\n for (const [h, entry] of Object.entries(syncState.entries || {})) {\n mappedByHash.set(h, entry);\n }\n const morgenCreate = [];\n const morgenClose = [];\n const dirtyFiles = new Map();\n const existingIdSet = new Set();\n for (const t of openTasks) if (t.morgenId) existingIdSet.add(t.morgenId);\n for (const t of doneTasks) if (t.morgenId) existingIdSet.add(t.morgenId);\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenId) existingIdSet.add(e.morgenId);\n }\n function mintUniqueId() {\n for (let attempt = 0; attempt < 10; attempt++) {\n const id = generateMorgenId();\n if (!existingIdSet.has(id)) { existingIdSet.add(id); return id; }\n }\n throw new Error('mintUniqueId: 10 collisions');\n }\n const mappedByMorgenId = new Map();\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenId) mappedByMorgenId.set(e.morgenId, { key: h, entry: e });\n }\n for (const task of openTasks) {\n if (task.morgenId) continue;\n const newId = mintUniqueId();\n const file = fileContents.find(function (f) { return f.path === task.sourceFile; });\n if (!file) continue;\n if (!isSafePath(task.sourceFile)) continue;\n const existingDirty = dirtyFiles.get(task.sourceFile);\n const baseContent = existingDirty ? existingDirty.newContent : file.content;\n const updatedLine = insertMorgenId(task.rawLine, newId);\n const idx = baseContent.indexOf(task.rawLine);\n if (idx === -1) continue;\n const newContent = baseContent.slice(0, idx) + updatedLine + baseContent.slice(idx + task.rawLine.length);\n dirtyFiles.set(task.sourceFile, {\n newContent: newContent,\n originalSha: existingDirty ? existingDirty.originalSha : file.sha,\n count: (existingDirty ? existingDirty.count : 0) + 1,\n });\n task.morgenId = newId;\n task.rawLine = updatedLine;\n }\n const morgenUpdate = [];\n for (const task of openTasks) {\n if (!task.morgenId) continue;\n const byId = mappedByMorgenId.get(task.morgenId);\n if (byId && byId.entry.morgenTaskId && !byId.entry.archived) {\n const e = byId.entry;\n const desiredLabels = getDesiredMorgenTagLabels(task);\n const currentLabels = Array.isArray(e.morgenTagLabels) ? e.morgenTagLabels : null;\n const tagsChanged = !currentLabels || !sameTagLabelSet(currentLabels, desiredLabels);\n const changed =\n (e.text !== task.text) ||\n (e.priority !== task.priority) ||\n ((e.due || null) !== (task.due || null)) ||\n tagsChanged;\n if (changed) morgenUpdate.push({ task, entry: e, desiredLabels, tagsChanged });\n continue;\n }\n const mapped = mappedByHash.get(task.hash);\n if (mapped && mapped.morgenTaskId && !mapped.archived) continue;\n morgenCreate.push(task);\n }\n for (const task of doneTasks) {\n if (task.morgenId) {\n const byId = mappedByMorgenId.get(task.morgenId);\n if (byId && byId.entry.morgenTaskId && !byId.entry.archived) {\n morgenClose.push({ entry: byId.entry, oldHash: byId.key });\n continue;\n }\n }\n for (const [h, entry] of mappedByHash) {\n if (entry.sourceFile === task.sourceFile && entry.text === task.text && entry.morgenTaskId && !entry.archived) {\n morgenClose.push({ entry, oldHash: h });\n break;\n }\n }\n }\n const neededAreaLabels = new Set();\n for (const t of morgenCreate) {\n for (const label of getDesiredMorgenTagLabels(t)) neededAreaLabels.add(label);\n }\n for (const u of morgenUpdate) {\n if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n for (const label of u.desiredLabels) neededAreaLabels.add(label);\n }\n }\n let uncachedTagCount = 0;\n for (const label of neededAreaLabels) {\n if (!syncState._tagCache[label]) uncachedTagCount++;\n }\n const morgenCallCount = morgenCreate.length + morgenClose.length + morgenUpdate.length;\n const willCallMorgen = morgenCallCount > 0 || uncachedTagCount > 0;\n const projectedPoints = (willCallMorgen ? 10 : 0) + uncachedTagCount + morgenCallCount;\n if (projectedPoints > RATE_BUDGET) {\n throw new Error('ABORT [#rate-budget] projected ' + projectedPoints + 'pts > ' + RATE_BUDGET);\n }\n const morgenErrors = [];\n if (willCallMorgen) {\n const tagsResp = await authedRequest('httpHeaderAuth', {\n method: 'GET',\n url: 'https://api.morgen.so/v3/tags/list',\n json: true,\n });\n let tags = [];\n if (Array.isArray(tagsResp)) tags = tagsResp;\n else if (tagsResp && Array.isArray(tagsResp.tags)) tags = tagsResp.tags;\n else if (tagsResp && tagsResp.data && Array.isArray(tagsResp.data.tags)) tags = tagsResp.data.tags;\n else if (tagsResp && Array.isArray(tagsResp.data)) tags = tagsResp.data;\n for (const tag of tags) {\n if (tag && tag.name && tag.id) syncState._tagCache[tag.name] = tag.id;\n }\n for (const label of neededAreaLabels) {\n if (!syncState._tagCache[label]) {\n try {\n const resp = await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tags/create',\n body: { name: label },\n json: true,\n });\n const newId =\n (resp && typeof resp.id === 'string' && resp.id) ||\n (resp && resp.data && typeof resp.data.id === 'string' && resp.data.id) ||\n (resp && resp.data && resp.data.tag && typeof resp.data.tag.id === 'string' && resp.data.tag.id) ||\n null;\n if (newId) {\n syncState._tagCache[label] = newId;\n } else {\n morgenErrors.push('tag-create ' + label + ': unexpected response shape');\n }\n } catch (e) {\n morgenErrors.push('tag-create ' + label + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n }\n for (const task of morgenCreate) {\n const desiredLabels = getDesiredMorgenTagLabels(task);\n const tagIds = [];\n for (const label of desiredLabels) {\n const id = syncState._tagCache[label];\n if (id) tagIds.push(id);\n }\n const body = {\n title: (task.text || '').slice(0, 500),\n description: 'From Obsidian: ' + task.sourceFile,\n priority: task.priority,\n taskListId: 'inbox',\n tags: tagIds,\n };\n if (task.due) body.due = dateToMorgenLocal(task.due);\n try {\n const resp = await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tasks/create',\n body,\n json: true,\n });\n const createdMorgenTaskId = resp && resp.data && resp.data.id;\n if (createdMorgenTaskId) {\n syncState = upsertMappingEntry(syncState, task.hash, {\n // notionPageId stays null on new entries (Notion is dropped 2026-05-04)\n notionPageId: null,\n morgenTaskId: createdMorgenTaskId,\n morgenId: task.morgenId || null,\n sourceFile: task.sourceFile,\n lineNo: task.lineNo,\n text: task.text,\n area: task.area,\n priority: task.priority,\n due: task.due,\n scheduled: task.scheduled,\n lineHash: computeLineHash(task.rawLine),\n morgenTagLabels: desiredLabels,\n });\n }\n } catch (e) {\n morgenErrors.push('task-create ' + task.hash + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n for (const u of morgenUpdate) {\n const updBody = { id: u.entry.morgenTaskId };\n if (u.entry.text !== u.task.text) updBody.title = (u.task.text || '').slice(0, 500);\n if (u.entry.priority !== u.task.priority) updBody.priority = u.task.priority;\n if ((u.entry.due || null) !== (u.task.due || null)) {\n updBody.due = u.task.due ? dateToMorgenLocal(u.task.due) : null;\n }\n if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n const tagIds = [];\n for (const label of u.desiredLabels) {\n const id = syncState._tagCache[label];\n if (id) tagIds.push(id);\n }\n updBody.tags = tagIds;\n }\n try {\n await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tasks/update',\n body: updBody,\n json: true,\n });\n const k = (function () {\n for (const h of Object.keys(syncState.entries || {})) {\n if (syncState.entries[h] && syncState.entries[h].morgenId === u.task.morgenId) return h;\n }\n return null;\n })();\n if (k) {\n syncState.entries[k].text = u.task.text;\n syncState.entries[k].priority = u.task.priority;\n syncState.entries[k].due = u.task.due;\n syncState.entries[k].scheduled = u.task.scheduled;\n syncState.entries[k].updatedAt = new Date().toISOString();\n syncState.entries[k].lineHash = computeLineHash(u.task.rawLine);\n if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n syncState.entries[k].morgenTagLabels = u.desiredLabels;\n }\n }\n } catch (e) {\n morgenErrors.push('task-update ' + u.task.morgenId + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n for (const c of morgenClose) {\n try {\n await authedRequest('httpHeaderAuth', {\n method: 'POST',\n url: 'https://api.morgen.so/v3/tasks/close',\n body: { id: c.entry.morgenTaskId },\n json: true,\n });\n syncState = upsertMappingEntry(syncState, c.oldHash, { archived: true });\n } catch (e) {\n morgenErrors.push('task-close ' + c.oldHash + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n }\n // ==========================================================================\n // 2026-05-04 cutover: removed back-fill of `notionPageId` on synced entries.\n // The field stays at its default (null) on entries created post-cutover.\n // Pre-cutover entries retain their non-null notionPageId values verbatim.\n // ==========================================================================\n const writeBackErrors = [];\n for (const [wbPath, info] of dirtyFiles) {\n if (!isSafePath(wbPath)) { writeBackErrors.push('unsafe-path ' + wbPath); continue; }\n const encodedMd = Buffer.from(info.newContent, 'utf-8').toString('base64');\n const msgMd = '[bot:W1-assign-id] inject ' + info.count + ' \ud83c\udd94 into ' + wbPath;\n const pb = { message: msgMd, content: encodedMd, branch: 'main', sha: info.originalSha };\n try {\n await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath,\n body: pb,\n json: true,\n });\n } catch (e) {\n const status = e && (e.statusCode || (e.response && e.response.statusCode));\n if (status === 409) {\n try {\n const fresh = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath + '?ref=main',\n json: true,\n });\n pb.sha = fresh.sha;\n await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath,\n body: pb,\n json: true,\n });\n } catch (e2) {\n writeBackErrors.push(wbPath + ' 409-retry: ' + String(e2 && e2.message || e2).slice(0, 80));\n }\n } else {\n writeBackErrors.push(wbPath + ': ' + String(e && e.message || e).slice(0, 80));\n }\n }\n await new Promise(function (r) { setTimeout(r, 1100); });\n }\n const stateJson = serializeSyncState(syncState);\n const encoded = Buffer.from(stateJson, 'utf-8').toString('base64');\n const commitMsg = '[bot:W1] sync-state: ' + morgenCreate.length + '+morgen, ' + morgenClose.length + '-morgen, ' + morgenUpdate.length + '~morgen';\n const putBody = { message: commitMsg, content: encoded, branch: 'main' };\n if (syncStateSha) putBody.sha = syncStateSha;\n async function putWithRetry() {\n try {\n return await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json',\n body: putBody,\n json: true,\n });\n } catch (e) {\n const status = e.statusCode || (e.response && e.response.statusCode);\n if (status !== 409) throw e;\n const fresh = await authedRequest('githubApi', {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json?ref=main',\n json: true,\n });\n putBody.sha = fresh.sha;\n return await authedRequest('githubApi', {\n method: 'PUT',\n url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json',\n body: putBody,\n json: true,\n });\n }\n }\n await putWithRetry();\n return [{\n json: {\n ok: true,\n timestamp: nowIso,\n files_processed: fileContents.length,\n parsed_open: openTasks.length,\n parsed_done: doneTasks.length,\n morgen_created: morgenCreate.length,\n morgen_closed: morgenClose.length,\n morgen_updated: morgenUpdate.length,\n morgen_points: projectedPoints,\n morgen_errors: morgenErrors.slice(0, 10),\n writeback_errors: writeBackErrors.slice(0, 10),\n commit_message: commitMsg,\n }\n }];\n} catch (e) {\n const safe = { message: String((e && e.message) || e), name: (e && e.name) || 'Error' };\n return [{ json: { error: safe, failed: true, timestamp: new Date().toISOString() } }];\n}\n" } } ], diff --git a/workflows/W2-morgen-task-completion-sync.json b/workflows/W2-morgen-task-completion-sync.json index 05bd388..ffd36b5 100644 --- a/workflows/W2-morgen-task-completion-sync.json +++ b/workflows/W2-morgen-task-completion-sync.json @@ -34,7 +34,7 @@ "parameters": { "mode": "runOnceForAllItems", "language": "javaScript", - "jsCode": "\n// ==== authedRequest shim v3 — hardcoded tokens, uses this.helpers.httpRequest ====\nconst __AUTH_GH = \"Bearer {{GITHUB_TOKEN}}\";\nconst __AUTH_NOTION = \"Bearer {{NOTION_TOKEN}}\";\nconst __AUTH_MORGEN = \"ApiKey {{MORGEN_KEY}}\";\nconst authedRequest = async (credType, options) => {\n const headers = Object.assign({}, options.headers || {});\n if (credType === 'githubApi') {\n headers['Authorization'] = __AUTH_GH;\n if (!headers['Accept']) headers['Accept'] = 'application/vnd.github+json';\n } else if (credType === 'notionApi') {\n headers['Authorization'] = __AUTH_NOTION;\n if (!headers['Notion-Version']) headers['Notion-Version'] = '2022-06-28';\n if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';\n } else if (credType === 'httpHeaderAuth') {\n headers['Authorization'] = __AUTH_MORGEN;\n } else {\n throw new Error('Unknown credType: ' + credType);\n }\n return await this.helpers.httpRequest(Object.assign({}, options, { headers }));\n};\n// ==== end shim v3 ====\n\n\n\n\n\n// ============================================================================\n// Workflow 2: Morgen-Task-Completion-Sync\n// Polls Morgen /v3/tasks/list every 15 minutes and propagates changes back\n// to Obsidian (via GitHub Git Data API) for 4 directions:\n// A. Completion: Morgen completed → flip source line [ ]→[x]\n// B. Edit: Morgen title/priority/due changed → rewrite source line\n// C. Soft-delete: mapping has morgenTaskId but Morgen no longer returns it\n// D. New: new Morgen-origin task → append new line to TASKS-{AREA}.md\n//\n// Safety rails: #26a malformed response · #26b outage detection ·\n// #27 auth failure · #28 flip-ratio guard · #29 point-budget\n// ============================================================================\n\nconst crypto = require('crypto');\n\n// ============================================================================\n// INLINED sync-helpers.js (canonical, do not edit — edit 06-Tasks/sync-helpers.js)\n// ============================================================================\n/**\n * sync-helpers.js — CANONICAL shared library for Obsidian ↔ Notion ↔ Morgen sync\n *\n * Locked 2026-04-14 after the 15-agent swarm cleanup. This is the ONE source\n * of truth. W1/W2/W3 Code nodes inline this verbatim. Scripts require() it.\n *\n * LOCKED DECISIONS (do not relitigate):\n * - Hash: SHA256(sourceFile::text::priority_int::due::scheduled).slice(0,24)\n * - Priority: integer (1/2/5/7/9), null → '0'\n * - Dates: bare YYYY-MM-DD (slice(0,10)) before hashing\n * - Area names: LITERAL Notion select values with number prefix + U+00B7 dot\n * - sync-state shape: { _version, _tagCache, entries: { : {...} } }\n * - Parser: fenced-code-block aware (skips ```tasks query blocks)\n *\n * Consumed by:\n * - n8n Workflow 1 (Obsidian-Git-Task-Sync) — inlined\n * - n8n Workflow 2 (Morgen-Task-Completion-Sync) — inlined\n * - n8n Workflow 3 (Notion-Done-To-Obsidian-Sync) — inlined\n * - 06-Tasks/scripts/morgen-backfill.js — via require\n * - 06-Tasks/scripts/sync-e2e-tests.js — via require\n */\n\n// ===========================================================================\n// Priority mapping\n// ===========================================================================\n// Obsidian Tasks plugin emoji ↔ Morgen int ↔ Notion select label.\n// Canonical mapping:\n// 🔺 highest → 1 → \"🔺 Highest\"\n// ⏫ high → 2 → \"⏫ High\"\n// 🔼 medium → 5 → \"🔼 Medium\"\n// 🔽 low → 7 → \"🔽 Low\"\n// ⏬ lowest → 9 → \"⏬ Lowest\"\n// 0 = undefined/none (Morgen default)\nconst PRIORITY_EMOJI_TO_INT = Object.freeze({\n '🔺': 1, '⏫': 2, '🔼': 5, '🔽': 7, '⏬': 9,\n});\nconst PRIORITY_INT_TO_EMOJI = Object.freeze({\n 1: '🔺', 2: '⏫', 5: '🔼', 7: '🔽', 9: '⏬',\n});\nconst PRIORITY_INT_TO_NOTION = Object.freeze({\n 1: '🔺 Highest', 2: '⏫ High', 5: '🔼 Medium', 7: '🔽 Low', 9: '⏬ Lowest',\n});\nconst PRIORITY_NOTION_TO_INT = Object.freeze({\n '🔺 Highest': 1, '⏫ High': 2, '🔼 Medium': 5, '🔽 Low': 7, '⏬ Lowest': 9,\n});\n\nfunction parseObsidianPriority(emoji) {\n if (emoji == null) return 0;\n const k = String(emoji);\n return Object.prototype.hasOwnProperty.call(PRIORITY_EMOJI_TO_INT, k)\n ? PRIORITY_EMOJI_TO_INT[k] : 0;\n}\nfunction morgenPriorityToObsidian(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return '';\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_EMOJI, n)\n ? PRIORITY_INT_TO_EMOJI[n] : '';\n}\nfunction morgenPriorityToNotion(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return null;\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_NOTION, n)\n ? PRIORITY_INT_TO_NOTION[n] : null;\n}\nfunction notionPriorityToInt(label) {\n if (label == null) return 0;\n const k = String(label);\n return Object.prototype.hasOwnProperty.call(PRIORITY_NOTION_TO_INT, k)\n ? PRIORITY_NOTION_TO_INT[k] : 0;\n}\n\n// ===========================================================================\n// Area mapping — LITERAL Notion select values with number prefix + U+00B7\n// ===========================================================================\n// Introspected live from Notion DB {{NOTION_DATABASE_ID}} on\n// 2026-04-14. These exact strings MUST be used when writing to Notion's Area\n// select or the API returns 400.\nconst NOTION_AREAS = Object.freeze({\n URGENT: '01 URGENT',\n GENERAL: '02 GENERAL',\n LORECRAFT: '03 LORECRAFT',\n BLOOM: '04 BLOOM',\n 'CART-BLANCHE': '05 CART-BLANCHE',\n 'FIDGETCODING-CONTENT': '06 FIDGETCODING · content',\n 'FIDGETCODING-MISC-BUILDING': '07 FIDGETCODING · misc-building',\n 'FUTURE-SCHEDULING': '08 FUTURE-SCHEDULING',\n 'LAVA-NETWORK': '09 LAVA-NETWORK',\n MMA: '10 MMA',\n PARZVL: '11 PARZVL',\n WAGMI: '12 WAGMI',\n});\n// Reverse: Notion area label → internal area key\nconst NOTION_AREA_TO_KEY = Object.freeze(\n Object.fromEntries(Object.entries(NOTION_AREAS).map(([k, v]) => [v, k]))\n);\n// Area key → source file path (relative to repo root, which is also 06-Tasks dir)\nconst AREA_TO_FILE = Object.freeze({\n URGENT: 'TASKS-URGENT.md',\n GENERAL: 'TASKS-GENERAL.md',\n LORECRAFT: 'TASKS-LORECRAFT.md',\n BLOOM: 'TASKS-BLOOM.md',\n 'CART-BLANCHE': 'TASKS-CART-BLANCHE.md',\n 'FIDGETCODING-CONTENT': 'FIDGETCODING/content/TASKS-FIDGETCODING-content.md',\n 'FIDGETCODING-MISC-BUILDING': 'FIDGETCODING/misc-building/TASKS-FIDGETCODING-misc-building.md',\n 'FUTURE-SCHEDULING': 'FUTURE-SCHEDULING/TASKS-FUTURE-SCHEDULING.md',\n 'LAVA-NETWORK': 'TASKS-LAVA-NETWORK.md',\n MMA: 'TASKS-MMA.md',\n PARZVL: 'TASKS-PARZVL.md',\n WAGMI: 'TASKS-WAGMI.md',\n});\n\n/**\n * parseArea(sourceFilePath) → internal area key (e.g. \"FIDGETCODING-CONTENT\")\n *\n * Path-based detection. FIDGETCODING parent hub (TASKS-FIDGETCODING.md with no\n * subfolder) is a query-only view and has no inline tasks in practice — if\n * encountered, we return FIDGETCODING-CONTENT as a safe fallback since tasks\n * shouldn't land there.\n */\nfunction parseArea(sourceFilePath) {\n if (sourceFilePath == null) return 'GENERAL';\n const raw = String(sourceFilePath);\n if (!raw) return 'GENERAL';\n const p = raw.replace(/\\\\/g, '/').replace(/^\\.\\//, '').replace(/^06-Tasks\\//, '');\n\n // FIDGETCODING subareas (check before the parent hub)\n if (/(^|\\/)FIDGETCODING\\/content\\//.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FIDGETCODING\\/misc-building\\//.test(p)) return 'FIDGETCODING-MISC-BUILDING';\n // Parent hub — query-only, but safe fallback\n if (/(^|\\/)FIDGETCODING\\/TASKS-FIDGETCODING\\.md$/.test(p)) return 'FIDGETCODING-CONTENT';\n\n // FUTURE-SCHEDULING\n if (/(^|\\/)FUTURE-SCHEDULING\\//.test(p)) return 'FUTURE-SCHEDULING';\n\n // Flat TASKS-{AREA}.md\n const seg = p.split('/').pop() || '';\n const m = seg.match(/^TASKS-([A-Za-z0-9][A-Za-z0-9_-]*)\\.md$/i);\n if (m) {\n const key = m[1].toUpperCase();\n if (Object.prototype.hasOwnProperty.call(AREA_TO_FILE, key)) return key;\n }\n return 'GENERAL';\n}\n\n/** Internal area key → Notion select label */\nfunction areaKeyToNotionLabel(key) {\n return NOTION_AREAS[key] || NOTION_AREAS.GENERAL;\n}\n/** Notion select label → internal area key */\nfunction notionLabelToAreaKey(label) {\n if (label == null) return 'GENERAL';\n return NOTION_AREA_TO_KEY[label] || 'GENERAL';\n}\n/** Internal area key → relative source file path (no 06-Tasks/ prefix) */\nfunction areaKeyToFile(key) {\n return AREA_TO_FILE[key] || AREA_TO_FILE.GENERAL;\n}\n\n// ===========================================================================\n// Path safety — allowlist check (Agent 8 S1 fix)\n// ===========================================================================\nconst SAFE_PATH_RE = /^(TASKS-(URGENT|GENERAL|LORECRAFT|BLOOM|CART-BLANCHE|LAVA-NETWORK|MMA|PARZVL|WAGMI)\\.md|FIDGETCODING\\/(content|misc-building)\\/TASKS-FIDGETCODING-(content|misc-building)\\.md|FIDGETCODING\\/TASKS-FIDGETCODING\\.md|FUTURE-SCHEDULING\\/TASKS-FUTURE-SCHEDULING\\.md)$/;\n\n/**\n * isSafePath(p) — true if p is a known task-file path within the allowlist.\n * Used to guard any filesystem write against a user-controlled path string\n * (e.g. a Notion Source File field an attacker could set to `../../etc/passwd`).\n */\nfunction isSafePath(p) {\n if (typeof p !== 'string') return false;\n if (p.includes('..') || p.includes('\\\\') || p.startsWith('/')) return false;\n // Accept with or without 06-Tasks/ prefix\n const normalized = p.replace(/^06-Tasks\\//, '');\n return SAFE_PATH_RE.test(normalized);\n}\n\n// ===========================================================================\n// Hashing — the single anchor for every upsert decision\n// ===========================================================================\n// taskHash = SHA256(sourceFile::text::priority_int::due_bare::scheduled_bare).slice(0,24)\n//\n// - priority is ALWAYS the integer form (0 when missing), never the label\n// - due/scheduled are ALWAYS bare YYYY-MM-DD (slice 0,10), never full ISO\n// - null/undefined serialize as '0' for priority, '' for dates\n// - text is the cleaned body (priority/date emojis already stripped)\nfunction computeTaskHash(input) {\n const i = input || {};\n const parts = [\n i.sourceFile == null ? '' : String(i.sourceFile),\n i.text == null ? '' : String(i.text),\n i.priority == null ? '0' : String(parseInt(i.priority, 10) || 0),\n i.due == null ? '' : String(i.due).slice(0, 10),\n i.scheduled == null ? '' : String(i.scheduled).slice(0, 10),\n ];\n return crypto.createHash('sha256').update(parts.join('::'), 'utf8').digest('hex').slice(0, 24);\n}\n\n/**\n * computeLineHash(rawLine) — hash of the literal markdown line after stripping\n * trailing whitespace. Used by W2/W3 to detect whether the source file still\n * matches what the mapping recorded (drift detection).\n */\nfunction computeLineHash(rawLine) {\n const s = rawLine == null ? '' : String(rawLine).replace(/\\s+$/, '');\n return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);\n}\n\n// ===========================================================================\n// Obsidian Tasks parser — fenced-code-block aware\n// ===========================================================================\nconst TASK_LINE_RE = /^(\\s*)([-*+])\\s+\\[([ xX/\\-!?*])\\]\\s+(.*)$/;\nconst DATE_RE = /(\\d{4}-\\d{2}-\\d{2})/;\nconst FENCE_RE = /^(\\s*)(```|~~~)/;\nconst PRIO_EMOJIS = ['🔺', '⏫', '🔼', '🔽', '⏬'];\n\nfunction extractDate(text, emoji) {\n if (!text) return null;\n const idx = text.indexOf(emoji);\n if (idx === -1) return null;\n const tail = text.slice(idx + emoji.length, idx + emoji.length + 32);\n const m = tail.match(DATE_RE);\n return m ? m[1] : null;\n}\nfunction extractPriorityEmoji(text) {\n if (!text) return '';\n for (const e of PRIO_EMOJIS) {\n if (text.indexOf(e) !== -1) return e;\n }\n return '';\n}\nfunction extractRecurrence(text) {\n if (!text) return null;\n const idx = text.indexOf('🔁');\n if (idx === -1) return null;\n const tail = text.slice(idx + '🔁'.length);\n const stopRe = /[📅⏳🛫✅⏫🔺🔼🔽⏬🆔]/;\n const si = tail.search(stopRe);\n const rule = (si === -1 ? tail : tail.slice(0, si)).trim();\n return rule || null;\n}\nfunction stripTaskMetadata(text) {\n if (!text) return '';\n let out = String(text);\n out = out.replace(/[🔺⏫🔼🔽⏬]/g, ' ');\n out = out.replace(/[📅⏳🛫✅]\\s*\\d{4}-\\d{2}-\\d{2}/g, ' ');\n out = out.replace(/🔁[^📅⏳🛫✅⏫🔺🔼🔽⏬🆔\\n]*/g, ' ');\n out = out.replace(/🆔\\s*\\S+/g, ' ');\n return out.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * parseObsidianTasks(markdown, sourceFilePath)\n * → [{text, priority, due, scheduled, start, done, doneDate, recurrence,\n * lineNo, rawLine, area, sourceFile, hash}]\n *\n * Fenced-code-block aware: lines inside ```...``` or ~~~...~~~ blocks are\n * skipped so the `` ```tasks `` query blocks in hub files don't get parsed\n * as real tasks.\n *\n * Never throws — malformed input → empty list or partial results.\n */\nfunction parseObsidianTasks(markdown, sourceFilePath) {\n const out = [];\n if (markdown == null) return out;\n let text;\n try { text = String(markdown); } catch (_) { return out; }\n text = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n\n const lines = text.split('\\n');\n const area = parseArea(sourceFilePath);\n const sourceFile = sourceFilePath == null ? '' : String(sourceFilePath).replace(/^06-Tasks\\//, '');\n\n let inFence = false;\n let fenceMarker = null;\n for (let i = 0; i < lines.length; i++) {\n const rawLine = lines[i];\n const fm = rawLine.match(FENCE_RE);\n if (fm) {\n if (!inFence) { inFence = true; fenceMarker = fm[2]; }\n else if (rawLine.trim().startsWith(fenceMarker)) { inFence = false; fenceMarker = null; }\n continue;\n }\n if (inFence) continue;\n\n let m;\n try { m = rawLine.match(TASK_LINE_RE); } catch (_) { m = null; }\n if (!m) continue;\n\n const statusChar = m[3];\n const body = m[4] || '';\n const done = statusChar === 'x' || statusChar === 'X';\n\n const priorityEmoji = extractPriorityEmoji(body);\n const priority = parseObsidianPriority(priorityEmoji);\n const due = extractDate(body, '📅');\n const scheduled = extractDate(body, '⏳');\n const start = extractDate(body, '🛫');\n const doneDate = extractDate(body, '✅');\n const recurrence = extractRecurrence(body);\n const cleanText = stripTaskMetadata(body);\n\n const task = {\n text: cleanText,\n priority,\n due: due || null,\n scheduled: scheduled || null,\n start: start || null,\n done,\n doneDate: doneDate || null,\n recurrence,\n lineNo: i + 1,\n rawLine,\n area,\n sourceFile,\n };\n task.hash = computeTaskHash(task);\n out.push(task);\n }\n return out;\n}\n\n// ===========================================================================\n// sync-state.json shape + helpers\n// ===========================================================================\n// Schema (v1, locked):\n// {\n// \"_version\": 1,\n// \"_tagCache\": { \"\": \"\" },\n// \"entries\": {\n// \"\": {\n// \"hash\": \"<24hex>\",\n// \"sourceFile\": \"TASKS-URGENT.md\", // relative, no 06-Tasks/ prefix\n// \"lineNo\": 17,\n// \"lineHash\": \"<16hex>\", // computeLineHash of rawLine\n// \"text\": \"...\",\n// \"area\": \"URGENT\", // internal key\n// \"priority\": 2, // int\n// \"due\": \"2026-04-15\", // bare YYYY-MM-DD or null\n// \"scheduled\": null,\n// \"notionPageId\": \"abc-1234-...\", // null if not mirrored\n// \"morgenTaskId\": \"tsk_abc...\", // null if not mirrored\n// \"morgenEventId\": null,\n// \"createdAt\": \"...\",\n// \"updatedAt\": \"...\",\n// \"lastSyncedAt\": \"...\",\n// \"archived\": false\n// }\n// }\n// }\nconst SYNC_STATE_VERSION = 1;\n\nfunction emptySyncState() {\n return { _version: SYNC_STATE_VERSION, _tagCache: {}, entries: {} };\n}\n\nfunction loadSyncState(rawJsonString) {\n if (rawJsonString == null || rawJsonString === '') return emptySyncState();\n let parsed;\n try { parsed = JSON.parse(String(rawJsonString)); } catch (_) { return emptySyncState(); }\n if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return emptySyncState();\n const s = emptySyncState();\n if (typeof parsed._version === 'number') s._version = parsed._version;\n if (parsed._tagCache && typeof parsed._tagCache === 'object' && !Array.isArray(parsed._tagCache)) {\n s._tagCache = Object.assign({}, parsed._tagCache);\n }\n if (parsed.entries && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {\n s.entries = {};\n for (const k of Object.keys(parsed.entries)) {\n const v = parsed.entries[k];\n if (v && typeof v === 'object') s.entries[k] = Object.assign({}, v);\n }\n }\n return s;\n}\n\nfunction serializeSyncState(state) {\n const safe = state && typeof state === 'object' ? state : emptySyncState();\n return JSON.stringify({\n _version: typeof safe._version === 'number' ? safe._version : SYNC_STATE_VERSION,\n _tagCache: safe._tagCache && typeof safe._tagCache === 'object' ? safe._tagCache : {},\n entries: safe.entries && typeof safe.entries === 'object' ? safe.entries : {},\n }, null, 2) + '\\n';\n}\n\nfunction upsertMappingEntry(state, hash, patch) {\n const base = state && typeof state === 'object' ? state : emptySyncState();\n const nextEntries = Object.assign({}, base.entries || {});\n const existing = nextEntries[hash] || {};\n const nowIso = new Date().toISOString();\n nextEntries[hash] = Object.assign(\n { hash, notionPageId: null, morgenTaskId: null, morgenEventId: null,\n createdAt: existing.createdAt || nowIso, archived: false },\n existing,\n patch || {},\n { hash, updatedAt: nowIso, lastSyncedAt: nowIso },\n );\n return {\n _version: typeof base._version === 'number' ? base._version : SYNC_STATE_VERSION,\n _tagCache: Object.assign({}, base._tagCache || {}),\n entries: nextEntries,\n };\n}\n\nfunction findByNotionId(state, notionPageId) {\n if (!state || !state.entries || notionPageId == null) return null;\n const target = String(notionPageId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && e.notionPageId && String(e.notionPageId) === target) return { hash, entry: e };\n }\n return null;\n}\nfunction findByMorgenId(state, morgenTaskId) {\n if (!state || !state.entries || morgenTaskId == null) return null;\n const target = String(morgenTaskId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && ((e.morgenTaskId && String(e.morgenTaskId) === target) ||\n (e.morgenEventId && String(e.morgenEventId) === target))) {\n return { hash, entry: e };\n }\n }\n return null;\n}\n\n// ===========================================================================\n// Line reconstruction + mutation\n// ===========================================================================\nfunction reconstructObsidianLine(task, existingLine) {\n const t = task || {};\n let indent = '';\n let bullet = '-';\n let statusChar = t.done ? 'x' : ' ';\n\n if (typeof existingLine === 'string' && existingLine.length > 0) {\n const m = existingLine.match(TASK_LINE_RE);\n if (m) {\n indent = m[1] || '';\n bullet = m[2] || '-';\n if (t.done === undefined) {\n statusChar = (m[3] === 'x' || m[3] === 'X') ? 'x' : ' ';\n }\n }\n }\n\n const tokens = [];\n if (t.text != null) tokens.push(String(t.text).trim());\n const prioEmoji = morgenPriorityToObsidian(t.priority);\n if (prioEmoji) tokens.push(prioEmoji);\n if (t.due) tokens.push('📅 ' + String(t.due).slice(0, 10));\n if (t.scheduled) tokens.push('⏳ ' + String(t.scheduled).slice(0, 10));\n if (t.start) tokens.push('🛫 ' + String(t.start).slice(0, 10));\n if (t.recurrence) tokens.push('🔁 ' + t.recurrence);\n if (t.done && t.doneDate) tokens.push('✅ ' + String(t.doneDate).slice(0, 10));\n\n return indent + bullet + ' [' + statusChar + '] ' + tokens.join(' ').replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * flipTaskDone(line) — minimal mutation: flip \"[ ]\" → \"[x]\" and append\n * \"✅ YYYY-MM-DD\" if not already present. Preserves indentation, bullet,\n * and everything else verbatim.\n */\nfunction flipTaskDone(line) {\n if (line == null) return '';\n const raw = String(line);\n const m = raw.match(TASK_LINE_RE);\n if (!m) return raw;\n const indent = m[1] || '';\n const bullet = m[2] || '-';\n const body = m[4] || '';\n const today = new Date().toISOString().slice(0, 10);\n let newBody = body;\n if (newBody.indexOf('✅') === -1) {\n newBody = newBody.replace(/\\s+$/, '') + ' ✅ ' + today;\n }\n return indent + bullet + ' [x] ' + newBody.trim();\n}\n\n// ===========================================================================\n// Morgen date conversion\n// ===========================================================================\n// Morgen task `due` accepts full ISO with Z or offset. Events `start` requires\n// LocalDateTime + separate timeZone field. For tasks we use task-style: the\n// bare date becomes `YYYY-MM-DDT09:00:00` (9am local) as a sensible default.\nfunction dateToMorgenLocal(dateStr) {\n if (dateStr == null || dateStr === '') return null;\n const s = String(dateStr).trim();\n if (!s) return null;\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s + 'T09:00:00';\n const m = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n if (m) return m[1] + 'T' + m[2];\n const m2 = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2})$/);\n if (m2) return m2[1] + 'T' + m2[2] + ':00';\n return null;\n}\n\n// ===========================================================================\n// Commit message helpers — [bot:Wx] prefix prevents echo loops\n// ===========================================================================\nconst BOT_COMMIT_PREFIXES = Object.freeze(['[bot:W1]', '[bot:W2]', '[bot:W3]', '[bot:backfill]']);\nfunction isBotCommitMessage(msg) {\n if (msg == null) return false;\n const s = String(msg);\n return BOT_COMMIT_PREFIXES.some(p => s.startsWith(p));\n}\n\n// ===========================================================================\n// Exports\n// ===========================================================================\n// (module.exports stripped; host must declare crypto before inlining)\n\n// ============================================================================\n// END inlined helpers\n// ============================================================================\n\n// --- constants / config ---------------------------------------------------\nconst OWNER = '{{GITHUB_OWNER}}';\nconst REPO = '{{GITHUB_REPO_NAME}}';\nconst BRANCH = 'main';\nconst MORGEN_LIST_URL = 'https://api.morgen.so/v3/tasks/list';\nconst MORGEN_LIST_COST = 10; // rate budget points for the list call\nconst POINT_BUDGET_CEILING = 85; // safety rail #29\nconst FLIP_RATIO_LIMIT = 0.5; // safety rail #28\nconst FLIP_RATIO_MIN_SAMPLE = 4; // Agent 7 floor fix\nconst COMMIT_PREFIX = '[bot:W2]';\nconst SYNC_STATE_PATH = '.sync-state.json';\n\n// Top-level try/catch — never allow the workflow to throw\ntry {\n // --- 1. POLL MORGEN --------------------------------------------------------\n let morgenResp;\n try {\n morgenResp = await authedRequest('httpHeaderAuth',\n {\n method: 'GET',\n url: MORGEN_LIST_URL,\n json: true,\n returnFullResponse: false,\n },\n );\n } catch (httpErr) {\n const status = (httpErr && (httpErr.statusCode || httpErr.httpCode)) || null;\n // Rail #27 — auth failure, abort early\n if (status === 401 || status === 403) {\n return [{\n json: {\n error: { message: 'Morgen auth failed: ' + status, name: 'AuthError' },\n failed: true,\n rail: '#27',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n throw httpErr;\n }\n\n // Rail #26a — malformed response shape\n if (!morgenResp || !morgenResp.data || !Array.isArray(morgenResp.data.tasks)) {\n return [{\n json: {\n error: { message: 'Malformed Morgen response: missing data.tasks[]', name: 'MalformedResponse' },\n failed: true,\n rail: '#26a',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n const allTasks = morgenResp.data.tasks;\n\n // Filter to native Morgen tasks only (skip external integration mirrors)\n const morgenTasks = allTasks.filter((t) => {\n const iid = t && t.integrationId;\n return iid == null || iid === 'morgen';\n });\n\n // --- 2. LOAD .sync-state.json FROM GITHUB ----------------------------------\n let syncStateRaw = '';\n let syncStateSha = null;\n try {\n const stateResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/contents/' + SYNC_STATE_PATH + '?ref=' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n if (stateResp && stateResp.content) {\n syncStateRaw = Buffer.from(stateResp.content, 'base64').toString('utf8');\n syncStateSha = stateResp.sha || null;\n }\n } catch (stateErr) {\n const status = (stateErr && (stateErr.statusCode || stateErr.httpCode)) || null;\n if (status === 401 || status === 403) {\n return [{\n json: {\n error: { message: 'GitHub auth failed loading sync-state: ' + status, name: 'AuthError' },\n failed: true,\n rail: '#27',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n if (status !== 404) throw stateErr;\n }\n\n const syncState = loadSyncState(syncStateRaw);\n\n // Count how many entries in mapping currently claim a morgenTaskId\n const trackedMorgenEntries = [];\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenTaskId) trackedMorgenEntries.push({ hash: h, entry: e });\n }\n const trackedCount = trackedMorgenEntries.length;\n\n // Rail #26b — Morgen returned zero tasks but mapping has tracked ones → outage\n if (morgenTasks.length === 0 && trackedCount > 0) {\n return [{\n json: {\n error: { message: 'Morgen returned 0 tasks but ' + trackedCount + ' mapping entries have morgenTaskId (suspected outage)', name: 'SuspectedOutage' },\n failed: true,\n rail: '#26b',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Build quick lookup: morgenTaskId → morgenTask\n const morgenById = new Map();\n for (const mt of morgenTasks) {\n if (mt && mt.id) morgenById.set(String(mt.id), mt);\n }\n\n // Reverse _tagCache lookup: tagUUID → notionLabel\n const tagUuidToLabel = {};\n const tagCache = syncState._tagCache || {};\n for (const label of Object.keys(tagCache)) {\n const uuid = tagCache[label];\n if (uuid) tagUuidToLabel[String(uuid)] = label;\n }\n\n // --- 3. CLASSIFY CHANGES (4 DIRECTIONS) ------------------------------------\n const dirtyFiles = new Map(); // path → { contentPromise, newContent, lines }\n const opsLog = { done: 0, edit: 0, newTask: 0, softDelete: 0, skipped: 0 };\n const operations = []; // for audit\n\n // Helper: lazily fetch a source file from GitHub\n const fileCache = new Map(); // path → { content, sha, lines }\n async function fetchFile(path) {\n if (fileCache.has(path)) return fileCache.get(path);\n let resp;\n try {\n resp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/contents/' + path + '?ref=' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n } catch (fetchErr) {\n const status = (fetchErr && (fetchErr.statusCode || fetchErr.httpCode)) || null;\n if (status === 404) {\n // New file — start with empty shell\n const empty = { content: '', sha: null, lines: [] };\n fileCache.set(path, empty);\n return empty;\n }\n throw fetchErr;\n }\n const content = resp && resp.content ? Buffer.from(resp.content, 'base64').toString('utf8') : '';\n const lines = content.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n');\n const rec = { content, sha: resp && resp.sha ? resp.sha : null, lines };\n fileCache.set(path, rec);\n return rec;\n }\n\n // Direction A + B + C: iterate tracked mapping entries\n for (const { hash, entry } of trackedMorgenEntries) {\n const mtid = String(entry.morgenTaskId);\n const morgenTask = morgenById.get(mtid);\n\n if (!morgenTask) {\n // Direction C — Soft-delete: task no longer exists in Morgen\n const srcPath = entry.sourceFile;\n if (!srcPath || !isSafePath(srcPath)) {\n opsLog.skipped++;\n operations.push({ kind: 'soft-delete', hash, skipped: 'unsafe-path', srcPath });\n continue;\n }\n const file = await fetchFile.call(this, srcPath);\n const lineIdx = (entry.lineNo || 1) - 1;\n const cur = file.lines[lineIdx];\n if (typeof cur !== 'string') {\n opsLog.skipped++;\n operations.push({ kind: 'soft-delete', hash, skipped: 'line-oor', srcPath });\n continue;\n }\n const m = cur.match(/^(\\s*)([-*+])\\s+\\[([ xX\\/\\-!?*])\\]/);\n if (!m || m[3] === 'x' || m[3] === 'X') {\n // Already done or not a task line — nothing to do\n opsLog.skipped++;\n operations.push({ kind: 'soft-delete', hash, skipped: 'already-done', srcPath });\n continue;\n }\n const flipped = flipTaskDone(cur);\n file.lines[lineIdx] = flipped;\n dirtyFiles.set(srcPath, true);\n opsLog.softDelete++;\n operations.push({ kind: 'soft-delete', hash, srcPath, lineNo: entry.lineNo });\n // Mark entry archived\n const nextState = upsertMappingEntry(syncState, hash, { archived: true });\n syncState.entries = nextState.entries;\n continue;\n }\n\n // We have the Morgen task — check for completion or edit\n const isCompleted = morgenTask.progress === 'completed';\n const srcPath = entry.sourceFile;\n\n if (!srcPath || !isSafePath(srcPath)) {\n opsLog.skipped++;\n operations.push({ kind: 'check', hash, skipped: 'unsafe-path', srcPath });\n continue;\n }\n\n const file = await fetchFile.call(this, srcPath);\n const lineIdx = (entry.lineNo || 1) - 1;\n const cur = file.lines[lineIdx];\n if (typeof cur !== 'string') {\n opsLog.skipped++;\n operations.push({ kind: 'check', hash, skipped: 'line-oor', srcPath });\n continue;\n }\n const lineMatch = cur.match(/^(\\s*)([-*+])\\s+\\[([ xX\\/\\-!?*])\\]/);\n if (!lineMatch) {\n opsLog.skipped++;\n operations.push({ kind: 'check', hash, skipped: 'not-task-line', srcPath });\n continue;\n }\n const alreadyDoneInSrc = lineMatch[3] === 'x' || lineMatch[3] === 'X';\n\n // Direction A — Completion\n if (isCompleted && !alreadyDoneInSrc) {\n const flipped = flipTaskDone(cur);\n file.lines[lineIdx] = flipped;\n dirtyFiles.set(srcPath, true);\n opsLog.done++;\n operations.push({ kind: 'done', hash, srcPath, lineNo: entry.lineNo });\n const nextState = upsertMappingEntry(syncState, hash, { archived: true });\n syncState.entries = nextState.entries;\n continue;\n }\n\n // Direction B — Edit detection via hash comparison\n // Build a \"what Morgen thinks this task is\" task object and compare hash\n const morgenTitle = (morgenTask.title != null ? String(morgenTask.title) : '').trim();\n const morgenPrioInt = (function () {\n const p = morgenTask.priority;\n const n = parseInt(p, 10);\n return Number.isFinite(n) ? n : 0;\n })();\n const morgenDue = morgenTask.due ? String(morgenTask.due).slice(0, 10) : null;\n\n const morgenView = {\n sourceFile: entry.sourceFile,\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: entry.scheduled || null,\n };\n const morgenHash = computeTaskHash(morgenView);\n\n if (morgenHash !== entry.hash && !alreadyDoneInSrc && !isCompleted) {\n // Edit direction — reconstruct the line\n const newTask = {\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: entry.scheduled || null,\n start: entry.start || null,\n recurrence: entry.recurrence || null,\n done: false,\n };\n const newLine = reconstructObsidianLine(newTask, cur);\n if (newLine !== cur) {\n file.lines[lineIdx] = newLine;\n dirtyFiles.set(srcPath, true);\n opsLog.edit++;\n operations.push({ kind: 'edit', hash, srcPath, lineNo: entry.lineNo, oldHash: entry.hash, newHash: morgenHash });\n const nextPatch = {\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n lineHash: computeLineHash(newLine),\n };\n const nextState = upsertMappingEntry(syncState, hash, nextPatch);\n syncState.entries = nextState.entries;\n }\n }\n }\n\n // Direction D — New Morgen-origin tasks (no matching mapping entry)\n const mappedMorgenIds = new Set();\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenTaskId) mappedMorgenIds.add(String(e.morgenTaskId));\n }\n\n for (const mt of morgenTasks) {\n if (!mt || !mt.id) continue;\n if (mappedMorgenIds.has(String(mt.id))) continue;\n if (mt.progress === 'completed') continue; // don't import already-completed tasks\n\n // Resolve area from first tag UUID\n let areaKey = 'GENERAL';\n if (Array.isArray(mt.tags) && mt.tags.length > 0) {\n const firstTagId = String(mt.tags[0]);\n const label = tagUuidToLabel[firstTagId];\n if (label) areaKey = notionLabelToAreaKey(label);\n }\n const targetPath = areaKeyToFile(areaKey);\n if (!isSafePath(targetPath)) {\n opsLog.skipped++;\n operations.push({ kind: 'new', morgenId: mt.id, skipped: 'unsafe-path', targetPath });\n continue;\n }\n\n const file = await fetchFile.call(this, targetPath);\n\n // Build a clean task object for reconstructObsidianLine\n const morgenTitle = (mt.title != null ? String(mt.title) : '').trim();\n if (!morgenTitle) {\n opsLog.skipped++;\n operations.push({ kind: 'new', morgenId: mt.id, skipped: 'empty-title' });\n continue;\n }\n const morgenPrioInt = (function () {\n const n = parseInt(mt.priority, 10);\n return Number.isFinite(n) ? n : 0;\n })();\n const morgenDue = mt.due ? String(mt.due).slice(0, 10) : null;\n\n const newTask = {\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: null,\n done: false,\n };\n const newLine = reconstructObsidianLine(newTask, null);\n\n // Append to file (ensure trailing newline after append)\n let newLineNo;\n if (file.lines.length > 0 && file.lines[file.lines.length - 1] === '') {\n newLineNo = file.lines.length; // 1-based = index of the slot we overwrite + 1\n file.lines[file.lines.length - 1] = newLine;\n file.lines.push('');\n } else {\n file.lines.push(newLine);\n newLineNo = file.lines.length; // 1-based\n file.lines.push('');\n }\n dirtyFiles.set(targetPath, true);\n opsLog.newTask++;\n\n // Create mapping entry\n const entryHash = computeTaskHash({\n sourceFile: targetPath,\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: null,\n });\n const patch = {\n sourceFile: targetPath,\n lineNo: newLineNo,\n lineHash: computeLineHash(newLine),\n text: morgenTitle,\n area: areaKey,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: null,\n morgenTaskId: String(mt.id),\n notionPageId: null,\n archived: false,\n };\n const nextState = upsertMappingEntry(syncState, entryHash, patch);\n syncState.entries = nextState.entries;\n mappedMorgenIds.add(String(mt.id));\n operations.push({ kind: 'new', morgenId: mt.id, srcPath: targetPath, lineNo: newLineNo, hash: entryHash });\n }\n\n // --- 4. SAFETY RAIL #28 — FLIP RATIO GUARD ---------------------------------\n const flipCount = opsLog.done + opsLog.softDelete;\n if (trackedCount >= FLIP_RATIO_MIN_SAMPLE) {\n const ratio = flipCount / Math.max(trackedCount, 1);\n if (ratio > FLIP_RATIO_LIMIT) {\n return [{\n json: {\n error: {\n message: 'Flip ratio guard tripped: ' + flipCount + '/' + trackedCount + ' = ' + ratio.toFixed(3) + ' > ' + FLIP_RATIO_LIMIT,\n name: 'FlipRatioExceeded',\n },\n failed: true,\n rail: '#28',\n ops: opsLog,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n }\n\n // --- 5. NOTHING TO DO? exit clean -----------------------------------------\n if (dirtyFiles.size === 0) {\n return [{\n json: {\n ok: true,\n noop: true,\n morgenTaskCount: morgenTasks.length,\n trackedCount,\n ops: opsLog,\n pointsConsumed: MORGEN_LIST_COST,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Always rewrite .sync-state.json when any mutation happened\n const newSyncStateContent = serializeSyncState(syncState);\n\n // --- 6. COMMIT VIA GITHUB GIT DATA API ------------------------------------\n // Step 6.1 — get head ref\n const refResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/refs/heads/' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n const headSha = refResp && refResp.object && refResp.object.sha;\n if (!headSha) {\n return [{\n json: {\n error: { message: 'Could not resolve head sha for branch ' + BRANCH, name: 'GitRefError' },\n failed: true,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Step 6.2 — get base commit (for base_tree)\n const headCommitResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/commits/' + headSha,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n const baseTreeSha = headCommitResp && headCommitResp.tree && headCommitResp.tree.sha;\n if (!baseTreeSha) {\n return [{\n json: {\n error: { message: 'Could not resolve base tree sha', name: 'GitTreeError' },\n failed: true,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Step 6.3 — create blobs for every dirty file + sync-state.json\n const treeItems = [];\n\n // Dirty task files\n for (const path of dirtyFiles.keys()) {\n if (!isSafePath(path)) {\n opsLog.skipped++;\n continue;\n }\n const file = fileCache.get(path);\n const newContent = file.lines.join('\\n');\n const blobResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/blobs',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n content: Buffer.from(newContent, 'utf8').toString('base64'),\n encoding: 'base64',\n },\n },\n );\n if (!blobResp || !blobResp.sha) {\n return [{\n json: { error: { message: 'Blob create failed for ' + path, name: 'BlobError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n treeItems.push({\n path: '06-Tasks/' + path,\n mode: '100644',\n type: 'blob',\n sha: blobResp.sha,\n });\n }\n\n // sync-state.json blob\n const stateBlobResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/blobs',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n content: Buffer.from(newSyncStateContent, 'utf8').toString('base64'),\n encoding: 'base64',\n },\n },\n );\n if (!stateBlobResp || !stateBlobResp.sha) {\n return [{\n json: { error: { message: 'Blob create failed for sync-state', name: 'BlobError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n treeItems.push({\n path: SYNC_STATE_PATH,\n mode: '100644',\n type: 'blob',\n sha: stateBlobResp.sha,\n });\n\n // Step 6.4 — create new tree\n const treeResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/trees',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n base_tree: baseTreeSha,\n tree: treeItems,\n },\n },\n );\n if (!treeResp || !treeResp.sha) {\n return [{\n json: { error: { message: 'Tree create failed', name: 'TreeError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n\n // Step 6.5 — create commit\n const commitMsg = COMMIT_PREFIX + ' morgen-sync: ' + opsLog.done + ' done, ' + opsLog.edit + ' edit, ' + opsLog.newTask + ' new, ' + opsLog.softDelete + ' soft-delete';\n const commitResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/commits',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n message: commitMsg,\n tree: treeResp.sha,\n parents: [headSha],\n },\n },\n );\n if (!commitResp || !commitResp.sha) {\n return [{\n json: { error: { message: 'Commit create failed', name: 'CommitError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n\n // Step 6.6 — update ref\n const patchRefResp = await authedRequest('githubApi',\n {\n method: 'PATCH',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/refs/heads/' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n sha: commitResp.sha,\n force: false,\n },\n },\n );\n\n // --- 7. RETURN SUMMARY -----------------------------------------------------\n return [{\n json: {\n ok: true,\n commitSha: commitResp.sha,\n commitMessage: commitMsg,\n ops: opsLog,\n operations,\n morgenTaskCount: morgenTasks.length,\n trackedCount,\n dirtyFileCount: dirtyFiles.size,\n pointsConsumed: MORGEN_LIST_COST,\n refPatched: !!patchRefResp,\n timestamp: new Date().toISOString(),\n },\n }];\n} catch (e) {\n const safe = {\n message: String((e && e.message) || e),\n name: (e && e.name) || 'Error',\n };\n return [{\n json: {\n error: safe,\n failed: true,\n timestamp: new Date().toISOString(),\n },\n }];\n}\n" + "jsCode": "\n// ==== authedRequest shim v3 \u2014 hardcoded tokens, uses this.helpers.httpRequest ====\nconst __AUTH_GH = \"Bearer {{GITHUB_TOKEN}}\";\nconst __AUTH_NOTION = \"\" /* notion dropped 2026-05-04 */;\nconst __AUTH_MORGEN = \"ApiKey {{MORGEN_KEY}}\";\nconst authedRequest = async (credType, options) => {\n const headers = Object.assign({}, options.headers || {});\n if (credType === 'githubApi') {\n headers['Authorization'] = __AUTH_GH;\n if (!headers['Accept']) headers['Accept'] = 'application/vnd.github+json';\n } else if (credType === 'notionApi') {\n headers['Authorization'] = __AUTH_NOTION;\n if (!headers['Notion-Version']) headers['Notion-Version'] = '2022-06-28';\n if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';\n } else if (credType === 'httpHeaderAuth') {\n headers['Authorization'] = __AUTH_MORGEN;\n } else {\n throw new Error('Unknown credType: ' + credType);\n }\n return await this.helpers.httpRequest(Object.assign({}, options, { headers }));\n};\n// ==== end shim v3 ====\n\n\n\n\n\n// ============================================================================\n// Workflow 2: Morgen-Task-Completion-Sync\n// Polls Morgen /v3/tasks/list every 15 minutes and propagates changes back\n// to Obsidian (via GitHub Git Data API) for 4 directions:\n// A. Completion: Morgen completed \u2192 flip source line [ ]\u2192[x]\n// B. Edit: Morgen title/priority/due changed \u2192 rewrite source line\n// C. Soft-delete: mapping has morgenTaskId but Morgen no longer returns it\n// D. New: new Morgen-origin task \u2192 append new line to TASKS-{AREA}.md\n//\n// Safety rails: #26a malformed response \u00b7 #26b outage detection \u00b7\n// #27 auth failure \u00b7 #28 flip-ratio guard \u00b7 #29 point-budget\n// ============================================================================\n\nconst crypto = require('crypto');\n\n// ============================================================================\n// INLINED sync-helpers.js (canonical, do not edit \u2014 edit 06-Tasks/sync-helpers.js)\n// ============================================================================\n/**\n * sync-helpers.js \u2014 CANONICAL shared library for Obsidian \u2194 Notion \u2194 Morgen sync\n *\n * Locked 2026-04-14 after the 15-agent swarm cleanup. This is the ONE source\n * of truth. W1/W2/W3 Code nodes inline this verbatim. Scripts require() it.\n *\n * LOCKED DECISIONS (do not relitigate):\n * - Hash: SHA256(sourceFile::text::priority_int::due::scheduled).slice(0,24)\n * - Priority: integer (1/2/5/7/9), null \u2192 '0'\n * - Dates: bare YYYY-MM-DD (slice(0,10)) before hashing\n * - Area names: LITERAL Notion select values with number prefix + U+00B7 dot\n * - sync-state shape: { _version, _tagCache, entries: { : {...} } }\n * - Parser: fenced-code-block aware (skips ```tasks query blocks)\n *\n * Consumed by:\n * - n8n Workflow 1 (Obsidian-Git-Task-Sync) \u2014 inlined\n * - n8n Workflow 2 (Morgen-Task-Completion-Sync) \u2014 inlined\n * - n8n Workflow 3 (Notion-Done-To-Obsidian-Sync) \u2014 inlined\n * - 06-Tasks/scripts/morgen-backfill.js \u2014 via require\n * - 06-Tasks/scripts/sync-e2e-tests.js \u2014 via require\n */\n\n// ===========================================================================\n// Priority mapping\n// ===========================================================================\n// Obsidian Tasks plugin emoji \u2194 Morgen int \u2194 Notion select label.\n// Canonical mapping:\n// \ud83d\udd3a highest \u2192 1 \u2192 \"\ud83d\udd3a Highest\"\n// \u23eb high \u2192 2 \u2192 \"\u23eb High\"\n// \ud83d\udd3c medium \u2192 5 \u2192 \"\ud83d\udd3c Medium\"\n// \ud83d\udd3d low \u2192 7 \u2192 \"\ud83d\udd3d Low\"\n// \u23ec lowest \u2192 9 \u2192 \"\u23ec Lowest\"\n// 0 = undefined/none (Morgen default)\nconst PRIORITY_EMOJI_TO_INT = Object.freeze({\n '\ud83d\udd3a': 1, '\u23eb': 2, '\ud83d\udd3c': 5, '\ud83d\udd3d': 7, '\u23ec': 9,\n});\nconst PRIORITY_INT_TO_EMOJI = Object.freeze({\n 1: '\ud83d\udd3a', 2: '\u23eb', 5: '\ud83d\udd3c', 7: '\ud83d\udd3d', 9: '\u23ec',\n});\nconst PRIORITY_INT_TO_NOTION = Object.freeze({\n 1: '\ud83d\udd3a Highest', 2: '\u23eb High', 5: '\ud83d\udd3c Medium', 7: '\ud83d\udd3d Low', 9: '\u23ec Lowest',\n});\nconst PRIORITY_NOTION_TO_INT = Object.freeze({\n '\ud83d\udd3a Highest': 1, '\u23eb High': 2, '\ud83d\udd3c Medium': 5, '\ud83d\udd3d Low': 7, '\u23ec Lowest': 9,\n});\n\nfunction parseObsidianPriority(emoji) {\n if (emoji == null) return 0;\n const k = String(emoji);\n return Object.prototype.hasOwnProperty.call(PRIORITY_EMOJI_TO_INT, k)\n ? PRIORITY_EMOJI_TO_INT[k] : 0;\n}\nfunction morgenPriorityToObsidian(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return '';\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_EMOJI, n)\n ? PRIORITY_INT_TO_EMOJI[n] : '';\n}\nfunction morgenPriorityToNotion(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return null;\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_NOTION, n)\n ? PRIORITY_INT_TO_NOTION[n] : null;\n}\nfunction notionPriorityToInt(label) {\n if (label == null) return 0;\n const k = String(label);\n return Object.prototype.hasOwnProperty.call(PRIORITY_NOTION_TO_INT, k)\n ? PRIORITY_NOTION_TO_INT[k] : 0;\n}\n\n// ===========================================================================\n// Area mapping \u2014 LITERAL Notion select values with number prefix + U+00B7\n// ===========================================================================\n// Introspected live from Notion DB /* notion dropped 2026-05-04 */ on\n// 2026-04-14. These exact strings MUST be used when writing to Notion's Area\n// select or the API returns 400.\nconst NOTION_AREAS = Object.freeze({\n URGENT: '01 URGENT',\n GENERAL: '02 GENERAL',\n LORECRAFT: '03 LORECRAFT',\n BLOOM: '04 BLOOM',\n 'CART-BLANCHE': '05 CART-BLANCHE',\n 'FIDGETCODING-CONTENT': '06 FIDGETCODING \u00b7 content',\n 'FIDGETCODING-MISC-BUILDING': '07 FIDGETCODING \u00b7 misc-building',\n 'FUTURE-SCHEDULING': '08 FUTURE-SCHEDULING',\n 'LAVA-NETWORK': '09 LAVA-NETWORK',\n MMA: '10 MMA',\n PARZVL: '11 PARZVL',\n WAGMI: '12 WAGMI',\n});\n// Reverse: Notion area label \u2192 internal area key\nconst NOTION_AREA_TO_KEY = Object.freeze(\n Object.fromEntries(Object.entries(NOTION_AREAS).map(([k, v]) => [v, k]))\n);\n// Area key \u2192 source file path (relative to repo root, which is also 06-Tasks dir)\nconst AREA_TO_FILE = Object.freeze({\n URGENT: 'TASKS-URGENT.md',\n GENERAL: 'TASKS-GENERAL.md',\n LORECRAFT: 'TASKS-LORECRAFT.md',\n BLOOM: 'TASKS-BLOOM.md',\n 'CART-BLANCHE': 'TASKS-CART-BLANCHE.md',\n 'FIDGETCODING-CONTENT': 'FIDGETCODING/content/TASKS-FIDGETCODING-content.md',\n 'FIDGETCODING-MISC-BUILDING': 'FIDGETCODING/misc-building/TASKS-FIDGETCODING-misc-building.md',\n 'FUTURE-SCHEDULING': 'FUTURE-SCHEDULING/TASKS-FUTURE-SCHEDULING.md',\n 'LAVA-NETWORK': 'TASKS-LAVA-NETWORK.md',\n MMA: 'TASKS-MMA.md',\n PARZVL: 'TASKS-PARZVL.md',\n WAGMI: 'TASKS-WAGMI.md',\n});\n\n/**\n * parseArea(sourceFilePath) \u2192 internal area key (e.g. \"FIDGETCODING-CONTENT\")\n *\n * Path-based detection. FIDGETCODING parent hub (TASKS-FIDGETCODING.md with no\n * subfolder) is a query-only view and has no inline tasks in practice \u2014 if\n * encountered, we return FIDGETCODING-CONTENT as a safe fallback since tasks\n * shouldn't land there.\n */\nfunction parseArea(sourceFilePath) {\n if (sourceFilePath == null) return 'GENERAL';\n const raw = String(sourceFilePath);\n if (!raw) return 'GENERAL';\n const p = raw.replace(/\\\\/g, '/').replace(/^\\.\\//, '').replace(/^06-Tasks\\//, '');\n\n // FIDGETCODING subareas (check before the parent hub)\n if (/(^|\\/)FIDGETCODING\\/content\\//.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FIDGETCODING\\/misc-building\\//.test(p)) return 'FIDGETCODING-MISC-BUILDING';\n // Parent hub \u2014 query-only, but safe fallback\n if (/(^|\\/)FIDGETCODING\\/TASKS-FIDGETCODING\\.md$/.test(p)) return 'FIDGETCODING-CONTENT';\n\n // FUTURE-SCHEDULING\n if (/(^|\\/)FUTURE-SCHEDULING\\//.test(p)) return 'FUTURE-SCHEDULING';\n\n // Flat TASKS-{AREA}.md\n const seg = p.split('/').pop() || '';\n const m = seg.match(/^TASKS-([A-Za-z0-9][A-Za-z0-9_-]*)\\.md$/i);\n if (m) {\n const key = m[1].toUpperCase();\n if (Object.prototype.hasOwnProperty.call(AREA_TO_FILE, key)) return key;\n }\n return 'GENERAL';\n}\n\n/** Internal area key \u2192 Notion select label */\nfunction areaKeyToNotionLabel(key) {\n return NOTION_AREAS[key] || NOTION_AREAS.GENERAL;\n}\n/** Notion select label \u2192 internal area key */\nfunction notionLabelToAreaKey(label) {\n if (label == null) return 'GENERAL';\n return NOTION_AREA_TO_KEY[label] || 'GENERAL';\n}\n/** Internal area key \u2192 relative source file path (no 06-Tasks/ prefix) */\nfunction areaKeyToFile(key) {\n return AREA_TO_FILE[key] || AREA_TO_FILE.GENERAL;\n}\n\n// ===========================================================================\n// Path safety \u2014 allowlist check (Agent 8 S1 fix)\n// ===========================================================================\nconst SAFE_PATH_RE = /^(TASKS-(URGENT|GENERAL|LORECRAFT|BLOOM|CART-BLANCHE|LAVA-NETWORK|MMA|PARZVL|WAGMI)\\.md|FIDGETCODING\\/(content|misc-building)\\/TASKS-FIDGETCODING-(content|misc-building)\\.md|FIDGETCODING\\/TASKS-FIDGETCODING\\.md|FUTURE-SCHEDULING\\/TASKS-FUTURE-SCHEDULING\\.md)$/;\n\n/**\n * isSafePath(p) \u2014 true if p is a known task-file path within the allowlist.\n * Used to guard any filesystem write against a user-controlled path string\n * (e.g. a Notion Source File field an attacker could set to `../../etc/passwd`).\n */\nfunction isSafePath(p) {\n if (typeof p !== 'string') return false;\n if (p.includes('..') || p.includes('\\\\') || p.startsWith('/')) return false;\n // Accept with or without 06-Tasks/ prefix\n const normalized = p.replace(/^06-Tasks\\//, '');\n return SAFE_PATH_RE.test(normalized);\n}\n\n// ===========================================================================\n// Hashing \u2014 the single anchor for every upsert decision\n// ===========================================================================\n// taskHash = SHA256(sourceFile::text::priority_int::due_bare::scheduled_bare).slice(0,24)\n//\n// - priority is ALWAYS the integer form (0 when missing), never the label\n// - due/scheduled are ALWAYS bare YYYY-MM-DD (slice 0,10), never full ISO\n// - null/undefined serialize as '0' for priority, '' for dates\n// - text is the cleaned body (priority/date emojis already stripped)\nfunction computeTaskHash(input) {\n const i = input || {};\n const parts = [\n i.sourceFile == null ? '' : String(i.sourceFile),\n i.text == null ? '' : String(i.text),\n i.priority == null ? '0' : String(parseInt(i.priority, 10) || 0),\n i.due == null ? '' : String(i.due).slice(0, 10),\n i.scheduled == null ? '' : String(i.scheduled).slice(0, 10),\n ];\n return crypto.createHash('sha256').update(parts.join('::'), 'utf8').digest('hex').slice(0, 24);\n}\n\n/**\n * computeLineHash(rawLine) \u2014 hash of the literal markdown line after stripping\n * trailing whitespace. Used by W2/W3 to detect whether the source file still\n * matches what the mapping recorded (drift detection).\n */\nfunction computeLineHash(rawLine) {\n const s = rawLine == null ? '' : String(rawLine).replace(/\\s+$/, '');\n return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);\n}\n\n// ===========================================================================\n// Obsidian Tasks parser \u2014 fenced-code-block aware\n// ===========================================================================\nconst TASK_LINE_RE = /^(\\s*)([-*+])\\s+\\[([ xX/\\-!?*])\\]\\s+(.*)$/;\nconst DATE_RE = /(\\d{4}-\\d{2}-\\d{2})/;\nconst FENCE_RE = /^(\\s*)(```|~~~)/;\nconst PRIO_EMOJIS = ['\ud83d\udd3a', '\u23eb', '\ud83d\udd3c', '\ud83d\udd3d', '\u23ec'];\n\nfunction extractDate(text, emoji) {\n if (!text) return null;\n const idx = text.indexOf(emoji);\n if (idx === -1) return null;\n const tail = text.slice(idx + emoji.length, idx + emoji.length + 32);\n const m = tail.match(DATE_RE);\n return m ? m[1] : null;\n}\nfunction extractPriorityEmoji(text) {\n if (!text) return '';\n for (const e of PRIO_EMOJIS) {\n if (text.indexOf(e) !== -1) return e;\n }\n return '';\n}\nfunction extractRecurrence(text) {\n if (!text) return null;\n const idx = text.indexOf('\ud83d\udd01');\n if (idx === -1) return null;\n const tail = text.slice(idx + '\ud83d\udd01'.length);\n const stopRe = /[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94]/;\n const si = tail.search(stopRe);\n const rule = (si === -1 ? tail : tail.slice(0, si)).trim();\n return rule || null;\n}\nfunction stripTaskMetadata(text) {\n if (!text) return '';\n let out = String(text);\n out = out.replace(/[\ud83d\udd3a\u23eb\ud83d\udd3c\ud83d\udd3d\u23ec]/g, ' ');\n out = out.replace(/[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705]\\s*\\d{4}-\\d{2}-\\d{2}/g, ' ');\n out = out.replace(/\ud83d\udd01[^\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94\\n]*/g, ' ');\n out = out.replace(/\ud83c\udd94\\s*\\S+/g, ' ');\n return out.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * parseObsidianTasks(markdown, sourceFilePath)\n * \u2192 [{text, priority, due, scheduled, start, done, doneDate, recurrence,\n * lineNo, rawLine, area, sourceFile, hash}]\n *\n * Fenced-code-block aware: lines inside ```...``` or ~~~...~~~ blocks are\n * skipped so the `` ```tasks `` query blocks in hub files don't get parsed\n * as real tasks.\n *\n * Never throws \u2014 malformed input \u2192 empty list or partial results.\n */\nfunction parseObsidianTasks(markdown, sourceFilePath) {\n const out = [];\n if (markdown == null) return out;\n let text;\n try { text = String(markdown); } catch (_) { return out; }\n text = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n\n const lines = text.split('\\n');\n const area = parseArea(sourceFilePath);\n const sourceFile = sourceFilePath == null ? '' : String(sourceFilePath).replace(/^06-Tasks\\//, '');\n\n let inFence = false;\n let fenceMarker = null;\n for (let i = 0; i < lines.length; i++) {\n const rawLine = lines[i];\n const fm = rawLine.match(FENCE_RE);\n if (fm) {\n if (!inFence) { inFence = true; fenceMarker = fm[2]; }\n else if (rawLine.trim().startsWith(fenceMarker)) { inFence = false; fenceMarker = null; }\n continue;\n }\n if (inFence) continue;\n\n let m;\n try { m = rawLine.match(TASK_LINE_RE); } catch (_) { m = null; }\n if (!m) continue;\n\n const statusChar = m[3];\n const body = m[4] || '';\n const done = statusChar === 'x' || statusChar === 'X';\n\n const priorityEmoji = extractPriorityEmoji(body);\n const priority = parseObsidianPriority(priorityEmoji);\n const due = extractDate(body, '\ud83d\udcc5');\n const scheduled = extractDate(body, '\u23f3');\n const start = extractDate(body, '\ud83d\udeeb');\n const doneDate = extractDate(body, '\u2705');\n const recurrence = extractRecurrence(body);\n const cleanText = stripTaskMetadata(body);\n\n const task = {\n text: cleanText,\n priority,\n due: due || null,\n scheduled: scheduled || null,\n start: start || null,\n done,\n doneDate: doneDate || null,\n recurrence,\n lineNo: i + 1,\n rawLine,\n area,\n sourceFile,\n };\n task.hash = computeTaskHash(task);\n out.push(task);\n }\n return out;\n}\n\n// ===========================================================================\n// sync-state.json shape + helpers\n// ===========================================================================\n// Schema (v1, locked):\n// {\n// \"_version\": 1,\n// \"_tagCache\": { \"\": \"\" },\n// \"entries\": {\n// \"\": {\n// \"hash\": \"<24hex>\",\n// \"sourceFile\": \"TASKS-URGENT.md\", // relative, no 06-Tasks/ prefix\n// \"lineNo\": 17,\n// \"lineHash\": \"<16hex>\", // computeLineHash of rawLine\n// \"text\": \"...\",\n// \"area\": \"URGENT\", // internal key\n// \"priority\": 2, // int\n// \"due\": \"2026-04-15\", // bare YYYY-MM-DD or null\n// \"scheduled\": null,\n// \"notionPageId\": \"abc-1234-...\", // null if not mirrored\n// \"morgenTaskId\": \"tsk_abc...\", // null if not mirrored\n// \"morgenEventId\": null,\n// \"createdAt\": \"...\",\n// \"updatedAt\": \"...\",\n// \"lastSyncedAt\": \"...\",\n// \"archived\": false\n// }\n// }\n// }\nconst SYNC_STATE_VERSION = 1;\n\nfunction emptySyncState() {\n return { _version: SYNC_STATE_VERSION, _tagCache: {}, entries: {} };\n}\n\nfunction loadSyncState(rawJsonString) {\n if (rawJsonString == null || rawJsonString === '') return emptySyncState();\n let parsed;\n try { parsed = JSON.parse(String(rawJsonString)); } catch (_) { return emptySyncState(); }\n if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return emptySyncState();\n const s = emptySyncState();\n if (typeof parsed._version === 'number') s._version = parsed._version;\n if (parsed._tagCache && typeof parsed._tagCache === 'object' && !Array.isArray(parsed._tagCache)) {\n s._tagCache = Object.assign({}, parsed._tagCache);\n }\n if (parsed.entries && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {\n s.entries = {};\n for (const k of Object.keys(parsed.entries)) {\n const v = parsed.entries[k];\n if (v && typeof v === 'object') s.entries[k] = Object.assign({}, v);\n }\n }\n return s;\n}\n\nfunction serializeSyncState(state) {\n const safe = state && typeof state === 'object' ? state : emptySyncState();\n return JSON.stringify({\n _version: typeof safe._version === 'number' ? safe._version : SYNC_STATE_VERSION,\n _tagCache: safe._tagCache && typeof safe._tagCache === 'object' ? safe._tagCache : {},\n entries: safe.entries && typeof safe.entries === 'object' ? safe.entries : {},\n }, null, 2) + '\\n';\n}\n\nfunction upsertMappingEntry(state, hash, patch) {\n const base = state && typeof state === 'object' ? state : emptySyncState();\n const nextEntries = Object.assign({}, base.entries || {});\n const existing = nextEntries[hash] || {};\n const nowIso = new Date().toISOString();\n nextEntries[hash] = Object.assign(\n { hash, notionPageId: null, morgenTaskId: null, morgenEventId: null,\n createdAt: existing.createdAt || nowIso, archived: false },\n existing,\n patch || {},\n { hash, updatedAt: nowIso, lastSyncedAt: nowIso },\n );\n return {\n _version: typeof base._version === 'number' ? base._version : SYNC_STATE_VERSION,\n _tagCache: Object.assign({}, base._tagCache || {}),\n entries: nextEntries,\n };\n}\n\nfunction findByNotionId(state, notionPageId) {\n if (!state || !state.entries || notionPageId == null) return null;\n const target = String(notionPageId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && e.notionPageId && String(e.notionPageId) === target) return { hash, entry: e };\n }\n return null;\n}\nfunction findByMorgenId(state, morgenTaskId) {\n if (!state || !state.entries || morgenTaskId == null) return null;\n const target = String(morgenTaskId);\n for (const hash of Object.keys(state.entries)) {\n const e = state.entries[hash];\n if (e && ((e.morgenTaskId && String(e.morgenTaskId) === target) ||\n (e.morgenEventId && String(e.morgenEventId) === target))) {\n return { hash, entry: e };\n }\n }\n return null;\n}\n\n// ===========================================================================\n// Line reconstruction + mutation\n// ===========================================================================\nfunction reconstructObsidianLine(task, existingLine) {\n const t = task || {};\n let indent = '';\n let bullet = '-';\n let statusChar = t.done ? 'x' : ' ';\n\n if (typeof existingLine === 'string' && existingLine.length > 0) {\n const m = existingLine.match(TASK_LINE_RE);\n if (m) {\n indent = m[1] || '';\n bullet = m[2] || '-';\n if (t.done === undefined) {\n statusChar = (m[3] === 'x' || m[3] === 'X') ? 'x' : ' ';\n }\n }\n }\n\n const tokens = [];\n if (t.text != null) tokens.push(String(t.text).trim());\n const prioEmoji = morgenPriorityToObsidian(t.priority);\n if (prioEmoji) tokens.push(prioEmoji);\n if (t.due) tokens.push('\ud83d\udcc5 ' + String(t.due).slice(0, 10));\n if (t.scheduled) tokens.push('\u23f3 ' + String(t.scheduled).slice(0, 10));\n if (t.start) tokens.push('\ud83d\udeeb ' + String(t.start).slice(0, 10));\n if (t.recurrence) tokens.push('\ud83d\udd01 ' + t.recurrence);\n if (t.done && t.doneDate) tokens.push('\u2705 ' + String(t.doneDate).slice(0, 10));\n\n return indent + bullet + ' [' + statusChar + '] ' + tokens.join(' ').replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * flipTaskDone(line) \u2014 minimal mutation: flip \"[ ]\" \u2192 \"[x]\" and append\n * \"\u2705 YYYY-MM-DD\" if not already present. Preserves indentation, bullet,\n * and everything else verbatim.\n */\nfunction flipTaskDone(line) {\n if (line == null) return '';\n const raw = String(line);\n const m = raw.match(TASK_LINE_RE);\n if (!m) return raw;\n const indent = m[1] || '';\n const bullet = m[2] || '-';\n const body = m[4] || '';\n const today = new Date().toISOString().slice(0, 10);\n let newBody = body;\n if (newBody.indexOf('\u2705') === -1) {\n newBody = newBody.replace(/\\s+$/, '') + ' \u2705 ' + today;\n }\n return indent + bullet + ' [x] ' + newBody.trim();\n}\n\n// ===========================================================================\n// Morgen date conversion\n// ===========================================================================\n// Morgen task `due` accepts full ISO with Z or offset. Events `start` requires\n// LocalDateTime + separate timeZone field. For tasks we use task-style: the\n// bare date becomes `YYYY-MM-DDT09:00:00` (9am local) as a sensible default.\nfunction dateToMorgenLocal(dateStr) {\n if (dateStr == null || dateStr === '') return null;\n const s = String(dateStr).trim();\n if (!s) return null;\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s + 'T09:00:00';\n const m = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n if (m) return m[1] + 'T' + m[2];\n const m2 = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2})$/);\n if (m2) return m2[1] + 'T' + m2[2] + ':00';\n return null;\n}\n\n// ===========================================================================\n// Commit message helpers \u2014 [bot:Wx] prefix prevents echo loops\n// ===========================================================================\nconst BOT_COMMIT_PREFIXES = Object.freeze(['[bot:W1]', '[bot:W2]', '[bot:W3]', '[bot:backfill]']);\nfunction isBotCommitMessage(msg) {\n if (msg == null) return false;\n const s = String(msg);\n return BOT_COMMIT_PREFIXES.some(p => s.startsWith(p));\n}\n\n// ===========================================================================\n// Exports\n// ===========================================================================\n// (module.exports stripped; host must declare crypto before inlining)\n\n// ============================================================================\n// END inlined helpers\n// ============================================================================\n\n// --- constants / config ---------------------------------------------------\nconst OWNER = '{{GITHUB_OWNER}}';\nconst REPO = '{{GITHUB_REPO_NAME}}';\nconst BRANCH = 'main';\nconst MORGEN_LIST_URL = 'https://api.morgen.so/v3/tasks/list';\nconst MORGEN_LIST_COST = 10; // rate budget points for the list call\nconst POINT_BUDGET_CEILING = 85; // safety rail #29\nconst FLIP_RATIO_LIMIT = 0.5; // safety rail #28\nconst FLIP_RATIO_MIN_SAMPLE = 4; // Agent 7 floor fix\nconst COMMIT_PREFIX = '[bot:W2]';\nconst SYNC_STATE_PATH = '.sync-state.json';\n\n// Top-level try/catch \u2014 never allow the workflow to throw\ntry {\n // --- 1. POLL MORGEN --------------------------------------------------------\n let morgenResp;\n try {\n morgenResp = await authedRequest('httpHeaderAuth',\n {\n method: 'GET',\n url: MORGEN_LIST_URL,\n json: true,\n returnFullResponse: false,\n },\n );\n } catch (httpErr) {\n const status = (httpErr && (httpErr.statusCode || httpErr.httpCode)) || null;\n // Rail #27 \u2014 auth failure, abort early\n if (status === 401 || status === 403) {\n return [{\n json: {\n error: { message: 'Morgen auth failed: ' + status, name: 'AuthError' },\n failed: true,\n rail: '#27',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n throw httpErr;\n }\n\n // Rail #26a \u2014 malformed response shape\n if (!morgenResp || !morgenResp.data || !Array.isArray(morgenResp.data.tasks)) {\n return [{\n json: {\n error: { message: 'Malformed Morgen response: missing data.tasks[]', name: 'MalformedResponse' },\n failed: true,\n rail: '#26a',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n const allTasks = morgenResp.data.tasks;\n\n // Filter to native Morgen tasks only (skip external integration mirrors)\n const morgenTasks = allTasks.filter((t) => {\n const iid = t && t.integrationId;\n return iid == null || iid === 'morgen';\n });\n\n // --- 2. LOAD .sync-state.json FROM GITHUB ----------------------------------\n let syncStateRaw = '';\n let syncStateSha = null;\n try {\n const stateResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/contents/' + SYNC_STATE_PATH + '?ref=' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n if (stateResp && stateResp.content) {\n syncStateRaw = Buffer.from(stateResp.content, 'base64').toString('utf8');\n syncStateSha = stateResp.sha || null;\n }\n } catch (stateErr) {\n const status = (stateErr && (stateErr.statusCode || stateErr.httpCode)) || null;\n if (status === 401 || status === 403) {\n return [{\n json: {\n error: { message: 'GitHub auth failed loading sync-state: ' + status, name: 'AuthError' },\n failed: true,\n rail: '#27',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n if (status !== 404) throw stateErr;\n }\n\n const syncState = loadSyncState(syncStateRaw);\n\n // Count how many entries in mapping currently claim a morgenTaskId\n const trackedMorgenEntries = [];\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenTaskId) trackedMorgenEntries.push({ hash: h, entry: e });\n }\n const trackedCount = trackedMorgenEntries.length;\n\n // Rail #26b \u2014 Morgen returned zero tasks but mapping has tracked ones \u2192 outage\n if (morgenTasks.length === 0 && trackedCount > 0) {\n return [{\n json: {\n error: { message: 'Morgen returned 0 tasks but ' + trackedCount + ' mapping entries have morgenTaskId (suspected outage)', name: 'SuspectedOutage' },\n failed: true,\n rail: '#26b',\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Build quick lookup: morgenTaskId \u2192 morgenTask\n const morgenById = new Map();\n for (const mt of morgenTasks) {\n if (mt && mt.id) morgenById.set(String(mt.id), mt);\n }\n\n // Reverse _tagCache lookup: tagUUID \u2192 notionLabel\n const tagUuidToLabel = {};\n const tagCache = syncState._tagCache || {};\n for (const label of Object.keys(tagCache)) {\n const uuid = tagCache[label];\n if (uuid) tagUuidToLabel[String(uuid)] = label;\n }\n\n // --- 3. CLASSIFY CHANGES (4 DIRECTIONS) ------------------------------------\n const dirtyFiles = new Map(); // path \u2192 { contentPromise, newContent, lines }\n const opsLog = { done: 0, edit: 0, newTask: 0, softDelete: 0, skipped: 0 };\n const operations = []; // for audit\n\n // Helper: lazily fetch a source file from GitHub\n const fileCache = new Map(); // path \u2192 { content, sha, lines }\n async function fetchFile(path) {\n if (fileCache.has(path)) return fileCache.get(path);\n let resp;\n try {\n resp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/contents/' + path + '?ref=' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n } catch (fetchErr) {\n const status = (fetchErr && (fetchErr.statusCode || fetchErr.httpCode)) || null;\n if (status === 404) {\n // New file \u2014 start with empty shell\n const empty = { content: '', sha: null, lines: [] };\n fileCache.set(path, empty);\n return empty;\n }\n throw fetchErr;\n }\n const content = resp && resp.content ? Buffer.from(resp.content, 'base64').toString('utf8') : '';\n const lines = content.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n');\n const rec = { content, sha: resp && resp.sha ? resp.sha : null, lines };\n fileCache.set(path, rec);\n return rec;\n }\n\n // Direction A + B + C: iterate tracked mapping entries\n for (const { hash, entry } of trackedMorgenEntries) {\n const mtid = String(entry.morgenTaskId);\n const morgenTask = morgenById.get(mtid);\n\n if (!morgenTask) {\n // Direction C \u2014 Soft-delete: task no longer exists in Morgen\n const srcPath = entry.sourceFile;\n if (!srcPath || !isSafePath(srcPath)) {\n opsLog.skipped++;\n operations.push({ kind: 'soft-delete', hash, skipped: 'unsafe-path', srcPath });\n continue;\n }\n const file = await fetchFile.call(this, srcPath);\n const lineIdx = (entry.lineNo || 1) - 1;\n const cur = file.lines[lineIdx];\n if (typeof cur !== 'string') {\n opsLog.skipped++;\n operations.push({ kind: 'soft-delete', hash, skipped: 'line-oor', srcPath });\n continue;\n }\n const m = cur.match(/^(\\s*)([-*+])\\s+\\[([ xX\\/\\-!?*])\\]/);\n if (!m || m[3] === 'x' || m[3] === 'X') {\n // Already done or not a task line \u2014 nothing to do\n opsLog.skipped++;\n operations.push({ kind: 'soft-delete', hash, skipped: 'already-done', srcPath });\n continue;\n }\n const flipped = flipTaskDone(cur);\n file.lines[lineIdx] = flipped;\n dirtyFiles.set(srcPath, true);\n opsLog.softDelete++;\n operations.push({ kind: 'soft-delete', hash, srcPath, lineNo: entry.lineNo });\n // Mark entry archived\n const nextState = upsertMappingEntry(syncState, hash, { archived: true });\n syncState.entries = nextState.entries;\n continue;\n }\n\n // We have the Morgen task \u2014 check for completion or edit\n const isCompleted = morgenTask.progress === 'completed';\n const srcPath = entry.sourceFile;\n\n if (!srcPath || !isSafePath(srcPath)) {\n opsLog.skipped++;\n operations.push({ kind: 'check', hash, skipped: 'unsafe-path', srcPath });\n continue;\n }\n\n const file = await fetchFile.call(this, srcPath);\n const lineIdx = (entry.lineNo || 1) - 1;\n const cur = file.lines[lineIdx];\n if (typeof cur !== 'string') {\n opsLog.skipped++;\n operations.push({ kind: 'check', hash, skipped: 'line-oor', srcPath });\n continue;\n }\n const lineMatch = cur.match(/^(\\s*)([-*+])\\s+\\[([ xX\\/\\-!?*])\\]/);\n if (!lineMatch) {\n opsLog.skipped++;\n operations.push({ kind: 'check', hash, skipped: 'not-task-line', srcPath });\n continue;\n }\n const alreadyDoneInSrc = lineMatch[3] === 'x' || lineMatch[3] === 'X';\n\n // Direction A \u2014 Completion\n if (isCompleted && !alreadyDoneInSrc) {\n const flipped = flipTaskDone(cur);\n file.lines[lineIdx] = flipped;\n dirtyFiles.set(srcPath, true);\n opsLog.done++;\n operations.push({ kind: 'done', hash, srcPath, lineNo: entry.lineNo });\n const nextState = upsertMappingEntry(syncState, hash, { archived: true });\n syncState.entries = nextState.entries;\n continue;\n }\n\n // Direction B \u2014 Edit detection via hash comparison\n // Build a \"what Morgen thinks this task is\" task object and compare hash\n const morgenTitle = (morgenTask.title != null ? String(morgenTask.title) : '').trim();\n const morgenPrioInt = (function () {\n const p = morgenTask.priority;\n const n = parseInt(p, 10);\n return Number.isFinite(n) ? n : 0;\n })();\n const morgenDue = morgenTask.due ? String(morgenTask.due).slice(0, 10) : null;\n\n const morgenView = {\n sourceFile: entry.sourceFile,\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: entry.scheduled || null,\n };\n const morgenHash = computeTaskHash(morgenView);\n\n if (morgenHash !== entry.hash && !alreadyDoneInSrc && !isCompleted) {\n // Edit direction \u2014 reconstruct the line\n const newTask = {\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: entry.scheduled || null,\n start: entry.start || null,\n recurrence: entry.recurrence || null,\n done: false,\n };\n const newLine = reconstructObsidianLine(newTask, cur);\n if (newLine !== cur) {\n file.lines[lineIdx] = newLine;\n dirtyFiles.set(srcPath, true);\n opsLog.edit++;\n operations.push({ kind: 'edit', hash, srcPath, lineNo: entry.lineNo, oldHash: entry.hash, newHash: morgenHash });\n const nextPatch = {\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n lineHash: computeLineHash(newLine),\n };\n const nextState = upsertMappingEntry(syncState, hash, nextPatch);\n syncState.entries = nextState.entries;\n }\n }\n }\n\n // Direction D \u2014 New Morgen-origin tasks (no matching mapping entry)\n const mappedMorgenIds = new Set();\n for (const h of Object.keys(syncState.entries || {})) {\n const e = syncState.entries[h];\n if (e && e.morgenTaskId) mappedMorgenIds.add(String(e.morgenTaskId));\n }\n\n for (const mt of morgenTasks) {\n if (!mt || !mt.id) continue;\n if (mappedMorgenIds.has(String(mt.id))) continue;\n if (mt.progress === 'completed') continue; // don't import already-completed tasks\n\n // Resolve area from first tag UUID\n let areaKey = 'GENERAL';\n if (Array.isArray(mt.tags) && mt.tags.length > 0) {\n const firstTagId = String(mt.tags[0]);\n const label = tagUuidToLabel[firstTagId];\n if (label) areaKey = notionLabelToAreaKey(label);\n }\n const targetPath = areaKeyToFile(areaKey);\n if (!isSafePath(targetPath)) {\n opsLog.skipped++;\n operations.push({ kind: 'new', morgenId: mt.id, skipped: 'unsafe-path', targetPath });\n continue;\n }\n\n const file = await fetchFile.call(this, targetPath);\n\n // Build a clean task object for reconstructObsidianLine\n const morgenTitle = (mt.title != null ? String(mt.title) : '').trim();\n if (!morgenTitle) {\n opsLog.skipped++;\n operations.push({ kind: 'new', morgenId: mt.id, skipped: 'empty-title' });\n continue;\n }\n const morgenPrioInt = (function () {\n const n = parseInt(mt.priority, 10);\n return Number.isFinite(n) ? n : 0;\n })();\n const morgenDue = mt.due ? String(mt.due).slice(0, 10) : null;\n\n const newTask = {\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: null,\n done: false,\n };\n const newLine = reconstructObsidianLine(newTask, null);\n\n // Append to file (ensure trailing newline after append)\n let newLineNo;\n if (file.lines.length > 0 && file.lines[file.lines.length - 1] === '') {\n newLineNo = file.lines.length; // 1-based = index of the slot we overwrite + 1\n file.lines[file.lines.length - 1] = newLine;\n file.lines.push('');\n } else {\n file.lines.push(newLine);\n newLineNo = file.lines.length; // 1-based\n file.lines.push('');\n }\n dirtyFiles.set(targetPath, true);\n opsLog.newTask++;\n\n // Create mapping entry\n const entryHash = computeTaskHash({\n sourceFile: targetPath,\n text: morgenTitle,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: null,\n });\n const patch = {\n sourceFile: targetPath,\n lineNo: newLineNo,\n lineHash: computeLineHash(newLine),\n text: morgenTitle,\n area: areaKey,\n priority: morgenPrioInt,\n due: morgenDue,\n scheduled: null,\n morgenTaskId: String(mt.id),\n notionPageId: null,\n archived: false,\n };\n const nextState = upsertMappingEntry(syncState, entryHash, patch);\n syncState.entries = nextState.entries;\n mappedMorgenIds.add(String(mt.id));\n operations.push({ kind: 'new', morgenId: mt.id, srcPath: targetPath, lineNo: newLineNo, hash: entryHash });\n }\n\n // --- 4. SAFETY RAIL #28 \u2014 FLIP RATIO GUARD ---------------------------------\n const flipCount = opsLog.done + opsLog.softDelete;\n if (trackedCount >= FLIP_RATIO_MIN_SAMPLE) {\n const ratio = flipCount / Math.max(trackedCount, 1);\n if (ratio > FLIP_RATIO_LIMIT) {\n return [{\n json: {\n error: {\n message: 'Flip ratio guard tripped: ' + flipCount + '/' + trackedCount + ' = ' + ratio.toFixed(3) + ' > ' + FLIP_RATIO_LIMIT,\n name: 'FlipRatioExceeded',\n },\n failed: true,\n rail: '#28',\n ops: opsLog,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n }\n\n // --- 5. NOTHING TO DO? exit clean -----------------------------------------\n if (dirtyFiles.size === 0) {\n return [{\n json: {\n ok: true,\n noop: true,\n morgenTaskCount: morgenTasks.length,\n trackedCount,\n ops: opsLog,\n pointsConsumed: MORGEN_LIST_COST,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Always rewrite .sync-state.json when any mutation happened\n const newSyncStateContent = serializeSyncState(syncState);\n\n // --- 6. COMMIT VIA GITHUB GIT DATA API ------------------------------------\n // Step 6.1 \u2014 get head ref\n const refResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/refs/heads/' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n const headSha = refResp && refResp.object && refResp.object.sha;\n if (!headSha) {\n return [{\n json: {\n error: { message: 'Could not resolve head sha for branch ' + BRANCH, name: 'GitRefError' },\n failed: true,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Step 6.2 \u2014 get base commit (for base_tree)\n const headCommitResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/commits/' + headSha,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n },\n );\n const baseTreeSha = headCommitResp && headCommitResp.tree && headCommitResp.tree.sha;\n if (!baseTreeSha) {\n return [{\n json: {\n error: { message: 'Could not resolve base tree sha', name: 'GitTreeError' },\n failed: true,\n timestamp: new Date().toISOString(),\n },\n }];\n }\n\n // Step 6.3 \u2014 create blobs for every dirty file + sync-state.json\n const treeItems = [];\n\n // Dirty task files\n for (const path of dirtyFiles.keys()) {\n if (!isSafePath(path)) {\n opsLog.skipped++;\n continue;\n }\n const file = fileCache.get(path);\n const newContent = file.lines.join('\\n');\n const blobResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/blobs',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n content: Buffer.from(newContent, 'utf8').toString('base64'),\n encoding: 'base64',\n },\n },\n );\n if (!blobResp || !blobResp.sha) {\n return [{\n json: { error: { message: 'Blob create failed for ' + path, name: 'BlobError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n treeItems.push({\n path: '06-Tasks/' + path,\n mode: '100644',\n type: 'blob',\n sha: blobResp.sha,\n });\n }\n\n // sync-state.json blob\n const stateBlobResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/blobs',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n content: Buffer.from(newSyncStateContent, 'utf8').toString('base64'),\n encoding: 'base64',\n },\n },\n );\n if (!stateBlobResp || !stateBlobResp.sha) {\n return [{\n json: { error: { message: 'Blob create failed for sync-state', name: 'BlobError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n treeItems.push({\n path: SYNC_STATE_PATH,\n mode: '100644',\n type: 'blob',\n sha: stateBlobResp.sha,\n });\n\n // Step 6.4 \u2014 create new tree\n const treeResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/trees',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n base_tree: baseTreeSha,\n tree: treeItems,\n },\n },\n );\n if (!treeResp || !treeResp.sha) {\n return [{\n json: { error: { message: 'Tree create failed', name: 'TreeError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n\n // Step 6.5 \u2014 create commit\n const commitMsg = COMMIT_PREFIX + ' morgen-sync: ' + opsLog.done + ' done, ' + opsLog.edit + ' edit, ' + opsLog.newTask + ' new, ' + opsLog.softDelete + ' soft-delete';\n const commitResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/commits',\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n message: commitMsg,\n tree: treeResp.sha,\n parents: [headSha],\n },\n },\n );\n if (!commitResp || !commitResp.sha) {\n return [{\n json: { error: { message: 'Commit create failed', name: 'CommitError' }, failed: true, timestamp: new Date().toISOString() },\n }];\n }\n\n // Step 6.6 \u2014 update ref\n const patchRefResp = await authedRequest('githubApi',\n {\n method: 'PATCH',\n url: 'https://api.github.com/repos/' + OWNER + '/' + REPO + '/git/refs/heads/' + BRANCH,\n json: true,\n headers: { 'Accept': 'application/vnd.github+json' },\n body: {\n sha: commitResp.sha,\n force: false,\n },\n },\n );\n\n // --- 7. RETURN SUMMARY -----------------------------------------------------\n return [{\n json: {\n ok: true,\n commitSha: commitResp.sha,\n commitMessage: commitMsg,\n ops: opsLog,\n operations,\n morgenTaskCount: morgenTasks.length,\n trackedCount,\n dirtyFileCount: dirtyFiles.size,\n pointsConsumed: MORGEN_LIST_COST,\n refPatched: !!patchRefResp,\n timestamp: new Date().toISOString(),\n },\n }];\n} catch (e) {\n const safe = {\n message: String((e && e.message) || e),\n name: (e && e.name) || 'Error',\n };\n return [{\n json: {\n error: safe,\n failed: true,\n timestamp: new Date().toISOString(),\n },\n }];\n}\n" } } ], @@ -56,4 +56,4 @@ "availableInMCP": true, "callerPolicy": "workflowsFromSameOwner" } -} +} \ No newline at end of file diff --git a/workflows/W3-notion-done-to-obsidian-sync.json b/workflows/W3-notion-done-to-obsidian-sync.json deleted file mode 100644 index cd57367..0000000 --- a/workflows/W3-notion-done-to-obsidian-sync.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "Notion-Done-To-Obsidian-Sync", - "description": "Bidirectional Notion->Obsidian sync every 15 min. Canonical helpers inlined, FIDGETCODING area fix, integer-priority hash, path safety guards, try/catch auth leak prevention, [bot:W3] commit prefix.", - "nodes": [ - { - "id": "15ee855a-707b-49d6-ad20-5eeb2464a9c6", - "name": "Every 15 Minutes", - "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.3, - "position": [ - 100, - 300 - ], - "parameters": { - "rule": { - "interval": [ - { - "field": "minutes", - "minutesInterval": 15 - } - ] - } - } - }, - { - "id": "761a10f2-cfe3-456a-973d-d042bbc6a891", - "name": "Notion Bidirectional Sync", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 300, - 300 - ], - "parameters": { - "mode": "runOnceForAllItems", - "language": "javaScript", - "jsCode": "\n// ==== authedRequest shim v3 — hardcoded tokens, uses this.helpers.httpRequest ====\nconst __AUTH_GH = \"Bearer {{GITHUB_TOKEN}}\";\nconst __AUTH_NOTION = \"Bearer {{NOTION_TOKEN}}\";\nconst __AUTH_MORGEN = \"ApiKey {{MORGEN_KEY}}\";\nconst authedRequest = async (credType, options) => {\n const headers = Object.assign({}, options.headers || {});\n if (credType === 'githubApi') {\n headers['Authorization'] = __AUTH_GH;\n if (!headers['Accept']) headers['Accept'] = 'application/vnd.github+json';\n } else if (credType === 'notionApi') {\n headers['Authorization'] = __AUTH_NOTION;\n if (!headers['Notion-Version']) headers['Notion-Version'] = '2022-06-28';\n if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';\n } else if (credType === 'httpHeaderAuth') {\n headers['Authorization'] = __AUTH_MORGEN;\n } else {\n throw new Error('Unknown credType: ' + credType);\n }\n return await this.helpers.httpRequest(Object.assign({}, options, { headers }));\n};\n// ==== end shim v3 ====\n\n\n\n\n\n\n// ============================================================================\n// Notion-Done-To-Obsidian-Sync (W3 bidirectional N->O, every 15 min)\n// Responsibilities:\n// 1. Notion Status=Done -> flip source [ ]->[x] (legacy behavior preserved)\n// 2. Notion row edit (text/area/priority/due/scheduled) -> rewrite source line\n// 3. New Notion row with empty Source File -> append task line to TASKS-{Area}.md\n// and back-populate Source File + Hash on the Notion page\n// 4. Notion row archived but mapping exists -> mark source [x] (safer than delete)\n// Conflict: last-writer-wins by lastSyncedTs (tie -> Obsidian). This workflow\n// only rewrites/creates when Notion last_edited_time > mapping.lastSyncedTs.\n//\n// Canonical helpers inlined verbatim from 06-Tasks/sync-helpers.js (2026-04-14).\n// All 5 bug fixes from cleanup-w3 applied:\n// 1. FIDGETCODING area resolution via notionLabelToAreaKey + areaKeyToFile\n// 2. Hash formula uses integer priority (notionPriorityToInt)\n// 3. Top-level try/catch (no auth header leak)\n// 4. isSafePath() guard on every user-controlled path\n// 5. [bot:W3] commit message prefix\n// ============================================================================\n\nconst crypto = require('crypto');\n\ntry {\n\n// === BEGIN INLINED HELPERS (sync-helpers.js, stripped module.exports) ======\n// ===========================================================================\n// Priority mapping\n// ===========================================================================\n// Obsidian Tasks plugin emoji ↔ Morgen int ↔ Notion select label.\n// Canonical mapping:\n// 🔺 highest → 1 → \"🔺 Highest\"\n// ⏫ high → 2 → \"⏫ High\"\n// 🔼 medium → 5 → \"🔼 Medium\"\n// 🔽 low → 7 → \"🔽 Low\"\n// ⏬ lowest → 9 → \"⏬ Lowest\"\n// 0 = undefined/none (Morgen default)\nconst PRIORITY_EMOJI_TO_INT = Object.freeze({\n '🔺': 1, '⏫': 2, '🔼': 5, '🔽': 7, '⏬': 9,\n});\nconst PRIORITY_INT_TO_EMOJI = Object.freeze({\n 1: '🔺', 2: '⏫', 5: '🔼', 7: '🔽', 9: '⏬',\n});\nconst PRIORITY_INT_TO_NOTION = Object.freeze({\n 1: '🔺 Highest', 2: '⏫ High', 5: '🔼 Medium', 7: '🔽 Low', 9: '⏬ Lowest',\n});\nconst PRIORITY_NOTION_TO_INT = Object.freeze({\n '🔺 Highest': 1, '⏫ High': 2, '🔼 Medium': 5, '🔽 Low': 7, '⏬ Lowest': 9,\n});\n\nfunction parseObsidianPriority(emoji) {\n if (emoji == null) return 0;\n const k = String(emoji);\n return Object.prototype.hasOwnProperty.call(PRIORITY_EMOJI_TO_INT, k)\n ? PRIORITY_EMOJI_TO_INT[k] : 0;\n}\nfunction morgenPriorityToObsidian(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return '';\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_EMOJI, n)\n ? PRIORITY_INT_TO_EMOJI[n] : '';\n}\nfunction morgenPriorityToNotion(intVal) {\n const n = Number(intVal);\n if (!Number.isFinite(n)) return null;\n return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_NOTION, n)\n ? PRIORITY_INT_TO_NOTION[n] : null;\n}\nfunction notionPriorityToInt(label) {\n if (label == null) return 0;\n const k = String(label);\n return Object.prototype.hasOwnProperty.call(PRIORITY_NOTION_TO_INT, k)\n ? PRIORITY_NOTION_TO_INT[k] : 0;\n}\n\n// ===========================================================================\n// Area mapping — LITERAL Notion select values with number prefix + U+00B7\n// ===========================================================================\n// Introspected live from Notion DB {{NOTION_DATABASE_ID}} on\n// 2026-04-14. These exact strings MUST be used when writing to Notion's Area\n// select or the API returns 400.\nconst NOTION_AREAS = Object.freeze({\n URGENT: '01 URGENT',\n GENERAL: '02 GENERAL',\n LORECRAFT: '03 LORECRAFT',\n BLOOM: '04 BLOOM',\n 'CART-BLANCHE': '05 CART-BLANCHE',\n 'FIDGETCODING-CONTENT': '06 FIDGETCODING · content',\n 'FIDGETCODING-MISC-BUILDING': '07 FIDGETCODING · misc-building',\n 'FUTURE-SCHEDULING': '08 FUTURE-SCHEDULING',\n 'LAVA-NETWORK': '09 LAVA-NETWORK',\n MMA: '10 MMA',\n PARZVL: '11 PARZVL',\n WAGMI: '12 WAGMI',\n});\n// Reverse: Notion area label → internal area key\nconst NOTION_AREA_TO_KEY = Object.freeze(\n Object.fromEntries(Object.entries(NOTION_AREAS).map(([k, v]) => [v, k]))\n);\n// Area key → source file path (relative to repo root, which is also 06-Tasks dir)\nconst AREA_TO_FILE = Object.freeze({\n URGENT: 'TASKS-URGENT.md',\n GENERAL: 'TASKS-GENERAL.md',\n LORECRAFT: 'TASKS-LORECRAFT.md',\n BLOOM: 'TASKS-BLOOM.md',\n 'CART-BLANCHE': 'TASKS-CART-BLANCHE.md',\n 'FIDGETCODING-CONTENT': 'FIDGETCODING/content/TASKS-FIDGETCODING-content.md',\n 'FIDGETCODING-MISC-BUILDING': 'FIDGETCODING/misc-building/TASKS-FIDGETCODING-misc-building.md',\n 'FUTURE-SCHEDULING': 'FUTURE-SCHEDULING/TASKS-FUTURE-SCHEDULING.md',\n 'LAVA-NETWORK': 'TASKS-LAVA-NETWORK.md',\n MMA: 'TASKS-MMA.md',\n PARZVL: 'TASKS-PARZVL.md',\n WAGMI: 'TASKS-WAGMI.md',\n});\n\n/**\n * parseArea(sourceFilePath) → internal area key (e.g. \"FIDGETCODING-CONTENT\")\n */\nfunction parseArea(sourceFilePath) {\n if (sourceFilePath == null) return 'GENERAL';\n const raw = String(sourceFilePath);\n if (!raw) return 'GENERAL';\n const p = raw.replace(/\\\\/g, '/').replace(/^\\.\\//, '').replace(/^06-Tasks\\//, '');\n\n // FIDGETCODING subareas (check before the parent hub)\n if (/(^|\\/)FIDGETCODING\\/content\\//.test(p)) return 'FIDGETCODING-CONTENT';\n if (/(^|\\/)FIDGETCODING\\/misc-building\\//.test(p)) return 'FIDGETCODING-MISC-BUILDING';\n // Parent hub — query-only, but safe fallback\n if (/(^|\\/)FIDGETCODING\\/TASKS-FIDGETCODING\\.md$/.test(p)) return 'FIDGETCODING-CONTENT';\n\n // FUTURE-SCHEDULING\n if (/(^|\\/)FUTURE-SCHEDULING\\//.test(p)) return 'FUTURE-SCHEDULING';\n\n // Flat TASKS-{AREA}.md\n const seg = p.split('/').pop() || '';\n const m = seg.match(/^TASKS-([A-Za-z0-9][A-Za-z0-9_-]*)\\.md$/i);\n if (m) {\n const key = m[1].toUpperCase();\n if (Object.prototype.hasOwnProperty.call(AREA_TO_FILE, key)) return key;\n }\n return 'GENERAL';\n}\n\n/** Internal area key → Notion select label */\nfunction areaKeyToNotionLabel(key) {\n return NOTION_AREAS[key] || NOTION_AREAS.GENERAL;\n}\n/** Notion select label → internal area key */\nfunction notionLabelToAreaKey(label) {\n if (label == null) return 'GENERAL';\n return NOTION_AREA_TO_KEY[label] || 'GENERAL';\n}\n/** Internal area key → relative source file path (no 06-Tasks/ prefix) */\nfunction areaKeyToFile(key) {\n return AREA_TO_FILE[key] || AREA_TO_FILE.GENERAL;\n}\n\n// ===========================================================================\n// Path safety — allowlist check (Agent 8 S1 fix)\n// ===========================================================================\nconst SAFE_PATH_RE = /^(TASKS-(URGENT|GENERAL|LORECRAFT|BLOOM|CART-BLANCHE|LAVA-NETWORK|MMA|PARZVL|WAGMI)\\.md|FIDGETCODING\\/(content|misc-building)\\/TASKS-FIDGETCODING-(content|misc-building)\\.md|FIDGETCODING\\/TASKS-FIDGETCODING\\.md|FUTURE-SCHEDULING\\/TASKS-FUTURE-SCHEDULING\\.md)$/;\n\nfunction isSafePath(p) {\n if (typeof p !== 'string') return false;\n if (p.includes('..') || p.includes('\\\\') || p.startsWith('/')) return false;\n const normalized = p.replace(/^06-Tasks\\//, '');\n return SAFE_PATH_RE.test(normalized);\n}\n\n// ===========================================================================\n// Hashing — the single anchor for every upsert decision\n// ===========================================================================\nfunction computeTaskHash(input) {\n const i = input || {};\n const parts = [\n i.sourceFile == null ? '' : String(i.sourceFile),\n i.text == null ? '' : String(i.text),\n i.priority == null ? '0' : String(parseInt(i.priority, 10) || 0),\n i.due == null ? '' : String(i.due).slice(0, 10),\n i.scheduled == null ? '' : String(i.scheduled).slice(0, 10),\n ];\n return crypto.createHash('sha256').update(parts.join('::'), 'utf8').digest('hex').slice(0, 24);\n}\n\nfunction computeLineHash(rawLine) {\n const s = rawLine == null ? '' : String(rawLine).replace(/\\s+$/, '');\n return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);\n}\n\n// ===========================================================================\n// Commit message helpers — [bot:Wx] prefix prevents echo loops\n// ===========================================================================\nconst BOT_COMMIT_PREFIXES = Object.freeze(['[bot:W1]', '[bot:W2]', '[bot:W3]', '[bot:backfill]']);\nfunction isBotCommitMessage(msg) {\n if (msg == null) return false;\n const s = String(msg);\n return BOT_COMMIT_PREFIXES.some(p => s.startsWith(p));\n}\n\n// === END INLINED HELPERS ===================================================\n\nconst NOTION_DB_ID = '{{NOTION_DATABASE_ID}}';\nconst REPO = '{{GITHUB_REPO}}';\nconst NOTION_VERSION = '2022-06-28';\nconst STATE_PATH = '.sync-state.json';\nconst MAX_FLIP_RATIO = 0.5;\n\nconst ABORT = (code, msg, extra) => {\n throw new Error('ABORT [' + code + '] ' + msg + (extra ? ' :: ' + JSON.stringify(extra) : ''));\n};\n\nconst now = new Date();\nconst today = now.toISOString().slice(0, 10);\nconst nowIso = now.toISOString();\n\n// Priority roundtrip: Notion select label <-> Obsidian emoji\nconst PRIO_NOTION_TO_EMOJI = {\n '🔺 Highest': '🔺',\n '⏫ High': '⏫',\n '🔼 Medium': '🔼',\n '🔽 Low': '🔽',\n '⏬ Lowest': '⏬',\n};\n\nfunction readProp(props, name, kind) {\n const p = props[name];\n if (!p) return null;\n if (kind === 'title') return (p.title && p.title[0] && p.title[0].plain_text) || null;\n if (kind === 'rich_text') return (p.rich_text && p.rich_text[0] && p.rich_text[0].plain_text) || null;\n if (kind === 'select') return (p.select && p.select.name) || null;\n if (kind === 'status') return (p.status && p.status.name) || null;\n if (kind === 'date') return (p.date && p.date.start) || null;\n return null;\n}\n\nfunction extractRow(page) {\n const props = page.properties || {};\n return {\n pageId: page.id,\n archived: !!page.archived,\n lastEditedTime: page.last_edited_time || null,\n task: readProp(props, 'Task', 'title'),\n area: readProp(props, 'Area', 'select'),\n priority: readProp(props, 'Priority', 'select'),\n status: readProp(props, 'Status', 'status'),\n due: readProp(props, 'Due', 'date'),\n scheduled: readProp(props, 'Scheduled', 'date'),\n parentTask: readProp(props, 'Parent Task', 'rich_text'),\n sourceFile: readProp(props, 'Source File', 'rich_text'),\n hash: readProp(props, 'Hash', 'rich_text'),\n lastSynced: readProp(props, 'Last Synced', 'date'),\n };\n}\n\nfunction buildObsidianLine(row, done) {\n const checkbox = done ? '- [x]' : '- [ ]';\n const parts = [checkbox, row.task];\n const emoji = row.priority && PRIO_NOTION_TO_EMOJI[row.priority];\n if (emoji) parts.push(emoji);\n if (row.scheduled) parts.push('⏳ ' + row.scheduled);\n if (row.due) parts.push('📅 ' + row.due);\n if (done) parts.push('✅ ' + today);\n return parts.join(' ');\n}\n\n// BUG FIX 2: hash wrapper uses canonical computeTaskHash with integer priority\nfunction hashForRow(row) {\n return computeTaskHash({\n sourceFile: row.sourceFile || '',\n text: (row.task || '').trim(),\n priority: notionPriorityToInt(row.priority),\n due: row.due,\n scheduled: row.scheduled,\n });\n}\n\n// ---------- step 1: paginated fetch of non-archived Notion rows ----------\nconst allPages = [];\nlet cursor = null;\ntry {\n do {\n const queryBody = { page_size: 100, archived: false };\n if (cursor) queryBody.start_cursor = cursor;\n const resp = await authedRequest('notionApi',\n {\n method: 'POST',\n url: 'https://api.notion.com/v1/databases/' + NOTION_DB_ID + '/query',\n headers: { 'Notion-Version': NOTION_VERSION, 'Content-Type': 'application/json' },\n body: queryBody,\n json: true,\n }\n );\n if (!resp || !Array.isArray(resp.results)) {\n ABORT('N01', 'Malformed Notion query response');\n }\n allPages.push(...resp.results);\n cursor = resp.has_more ? resp.next_cursor : null;\n } while (cursor);\n} catch (e) {\n ABORT('N02', 'Notion DB query failed', { error: String(e && e.message || e) });\n}\n\nif (allPages.length === 0) {\n ABORT('N03', 'Notion returned 0 rows - probable outage, refusing to proceed');\n}\n\nconst rows = allPages.map(extractRow);\n\n// ---------- step 2: load .sync-state.json from GitHub ----------\nlet syncState = { version: 1, mappings: {}, lastRun: null };\nlet syncStateSha = null;\ntry {\n const resp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + STATE_PATH + '?ref=main',\n headers: { 'Accept': 'application/vnd.github+json' },\n json: true,\n }\n );\n const raw = Buffer.from(resp.content, 'base64').toString('utf-8');\n try { syncState = JSON.parse(raw); } catch (_) { syncState = { version: 1, mappings: {}, lastRun: null }; }\n syncStateSha = resp.sha;\n} catch (_) {\n syncState = { version: 1, mappings: {}, lastRun: null };\n}\nif (!syncState.mappings) syncState.mappings = {};\n\n// ---------- step 3: plan actions ----------\nconst actions = { flipDone: [], editLine: [], createLine: [], archiveFlip: [] };\n\nfor (const row of rows) {\n if (!row.task) continue;\n const mapping = syncState.mappings[row.pageId];\n // BUG FIX 2: use hashForRow (integer priority)\n const newHash = hashForRow(row);\n\n if (mapping) {\n const notionEditedTs = row.lastEditedTime ? Date.parse(row.lastEditedTime) : 0;\n const mappingTs = mapping.lastSyncedTs ? Date.parse(mapping.lastSyncedTs) : 0;\n const notionIsNewer = notionEditedTs > mappingTs;\n\n if (row.status === 'Done') {\n actions.flipDone.push({ row, mapping });\n } else if (notionIsNewer && newHash !== mapping.hash) {\n actions.editLine.push({ row, mapping, newHash });\n }\n } else {\n if (!row.sourceFile) {\n actions.createLine.push({ row, newHash });\n }\n }\n}\n\n// Archived pass (best-effort, only if we already have mappings)\nif (Object.keys(syncState.mappings).length > 0) {\n let aCursor = null;\n try {\n do {\n const archivedBody = { page_size: 100, archived: true };\n if (aCursor) archivedBody.start_cursor = aCursor;\n const resp = await authedRequest('notionApi',\n {\n method: 'POST',\n url: 'https://api.notion.com/v1/databases/' + NOTION_DB_ID + '/query',\n headers: { 'Notion-Version': NOTION_VERSION, 'Content-Type': 'application/json' },\n body: archivedBody,\n json: true,\n }\n );\n const hits = (resp && resp.results) || [];\n for (const page of hits) {\n const r = extractRow(page);\n const m = syncState.mappings[r.pageId];\n if (m) actions.archiveFlip.push({ row: r, mapping: m });\n }\n aCursor = resp && resp.has_more ? resp.next_cursor : null;\n } while (aCursor);\n } catch (_) { /* non-fatal */ }\n}\n\n// ---------- Safety rail: abort if >50% of tracked would flip ----------\nconst trackedCount = Math.max(1, Object.keys(syncState.mappings).length);\nconst flipCount = actions.flipDone.length + actions.archiveFlip.length;\nif (trackedCount >= 4 && (flipCount / trackedCount) > MAX_FLIP_RATIO) {\n ABORT('N04', 'Flip ratio exceeds 50% - refusing catastrophic run', {\n flipCount, trackedCount, ratio: flipCount / trackedCount,\n });\n}\n\nconst totalActions = actions.flipDone.length + actions.editLine.length\n + actions.createLine.length + actions.archiveFlip.length;\nif (totalActions === 0) {\n return [{ json: {\n ok: true, tick: nowIso, tracked: trackedCount, totalNotionRows: rows.length,\n message: 'No actions - already in sync',\n } }];\n}\n\n// ---------- step 4: fetch affected source files ----------\nconst targetFiles = new Set();\nfor (const a of actions.flipDone) targetFiles.add(a.mapping.sourceFile);\nfor (const a of actions.editLine) targetFiles.add(a.mapping.sourceFile);\nfor (const a of actions.archiveFlip) targetFiles.add(a.mapping.sourceFile);\nfor (const a of actions.createLine) {\n // BUG FIX 1: FIDGETCODING area resolution via canonical helpers\n const areaKey = notionLabelToAreaKey(a.row.area);\n const relFile = areaKeyToFile(areaKey);\n a._targetFile = relFile;\n targetFiles.add(relFile);\n}\n\nconst applied = { flipDone: 0, editLine: 0, createLine: 0, archiveFlip: 0, missed: [] };\n\nconst fileCache = {};\nfor (const path of targetFiles) {\n // BUG FIX 4: validate every path before the GET\n if (!isSafePath(path)) {\n applied.missed.push({ kind: 'fetch', path, reason: 'unsafe-path' });\n continue;\n }\n try {\n const resp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/contents/' + path + '?ref=main',\n headers: { 'Accept': 'application/vnd.github+json' },\n json: true,\n }\n );\n const content = Buffer.from(resp.content, 'base64').toString('utf-8').replace(/\\r\\n/g, '\\n');\n fileCache[path] = { lines: content.split('\\n'), sha: resp.sha, dirty: false };\n } catch (_) {\n fileCache[path] = {\n lines: ['---','title: TASKS','type: moc','---','','## Open',''],\n sha: null, dirty: true,\n };\n }\n}\n\n// ---------- step 5: apply actions ----------\n\nfunction findOpenLine(lines, taskText) {\n for (let i = 0; i < lines.length; i++) {\n const ln = lines[i];\n if (!/^\\s*- \\[ \\]/.test(ln)) continue;\n if (taskText && ln.indexOf(taskText) !== -1) return i;\n }\n return -1;\n}\n\nfor (const a of actions.flipDone) {\n // BUG FIX 4: path safety defense in depth\n if (!isSafePath(a.mapping.sourceFile)) {\n applied.missed.push({ kind: 'flipDone', pageId: a.row.pageId, reason: 'unsafe-path' });\n continue;\n }\n const f = fileCache[a.mapping.sourceFile];\n if (!f) { applied.missed.push({ kind: 'flipDone', pageId: a.row.pageId, reason: 'file-not-fetched' }); continue; }\n const idx = findOpenLine(f.lines, a.row.task);\n if (idx === -1) { applied.missed.push({ kind: 'flipDone', pageId: a.row.pageId, reason: 'line-not-open' }); continue; }\n f.lines[idx] = f.lines[idx].replace(/^(\\s*)- \\[ \\]/, '$1- [x] ✅ ' + today);\n f.dirty = true;\n applied.flipDone++;\n syncState.mappings[a.row.pageId] = Object.assign({}, a.mapping, { lastSyncedTs: nowIso });\n}\n\nfor (const a of actions.editLine) {\n // BUG FIX 4: path safety defense in depth\n if (!isSafePath(a.mapping.sourceFile)) {\n applied.missed.push({ kind: 'editLine', pageId: a.row.pageId, reason: 'unsafe-path' });\n continue;\n }\n const f = fileCache[a.mapping.sourceFile];\n if (!f) { applied.missed.push({ kind: 'editLine', pageId: a.row.pageId, reason: 'file-not-fetched' }); continue; }\n const idx = findOpenLine(f.lines, a.mapping.task || a.row.task);\n if (idx === -1) { applied.missed.push({ kind: 'editLine', pageId: a.row.pageId, reason: 'no-matching-open-line' }); continue; }\n const indentMatch = f.lines[idx].match(/^(\\s*)/);\n const indent = indentMatch ? indentMatch[1] : '';\n f.lines[idx] = indent + buildObsidianLine(a.row, false);\n f.dirty = true;\n applied.editLine++;\n syncState.mappings[a.row.pageId] = {\n sourceFile: a.mapping.sourceFile,\n hash: a.newHash,\n lastSyncedTs: nowIso,\n task: a.row.task, area: a.row.area, priority: a.row.priority,\n due: a.row.due, scheduled: a.row.scheduled,\n };\n}\n\nconst createdPages = [];\nfor (const a of actions.createLine) {\n const f = fileCache[a._targetFile];\n if (!f) { applied.missed.push({ kind: 'createLine', pageId: a.row.pageId, reason: 'file-not-fetched' }); continue; }\n let insertIdx = -1;\n for (let i = 0; i < f.lines.length; i++) {\n if (/^##\\s*Open\\b/.test(f.lines[i])) { insertIdx = i + 1; break; }\n }\n if (insertIdx === -1) {\n f.lines.push('', '## Open', '');\n insertIdx = f.lines.length;\n }\n while (insertIdx < f.lines.length && f.lines[insertIdx].trim() === '') insertIdx++;\n const newLine = buildObsidianLine(a.row, false);\n f.lines.splice(insertIdx, 0, newLine);\n f.dirty = true;\n applied.createLine++;\n // BUG FIX 2: hashForRow (integer priority) with derived sourceFile\n const rowWithFile = Object.assign({}, a.row, { sourceFile: a._targetFile });\n const newHash = hashForRow(rowWithFile);\n syncState.mappings[a.row.pageId] = {\n sourceFile: a._targetFile, hash: newHash, lastSyncedTs: nowIso,\n task: a.row.task, area: a.row.area, priority: a.row.priority,\n due: a.row.due, scheduled: a.row.scheduled,\n };\n createdPages.push({ pageId: a.row.pageId, sourceFile: a._targetFile, hash: newHash });\n}\n\nfor (const a of actions.archiveFlip) {\n // BUG FIX 4: path safety defense in depth\n if (!isSafePath(a.mapping.sourceFile)) {\n applied.missed.push({ kind: 'archiveFlip', pageId: a.row.pageId, reason: 'unsafe-path' });\n continue;\n }\n const f = fileCache[a.mapping.sourceFile];\n if (!f) { applied.missed.push({ kind: 'archiveFlip', pageId: a.row.pageId, reason: 'file-not-fetched' }); continue; }\n const idx = findOpenLine(f.lines, a.mapping.task || a.row.task);\n if (idx === -1) { applied.missed.push({ kind: 'archiveFlip', pageId: a.row.pageId, reason: 'line-not-open' }); continue; }\n f.lines[idx] = f.lines[idx].replace(/^(\\s*)- \\[ \\]/, '$1- [x] ✅ ' + today);\n f.dirty = true;\n applied.archiveFlip++;\n delete syncState.mappings[a.row.pageId];\n}\n\n// ---------- step 6: single batched git commit (tree API) ----------\nsyncState.lastRun = nowIso;\nconst dirtyEntries = Object.entries(fileCache).filter(([_, v]) => v.dirty);\nconst stateJson = JSON.stringify(syncState, null, 2) + '\\n';\nconst writeState = applied.flipDone + applied.editLine + applied.createLine + applied.archiveFlip > 0;\n\nif (dirtyEntries.length === 0 && !writeState) {\n return [{ json: { ok: true, tick: nowIso, applied, message: 'No dirty files' } }];\n}\n\nconst refResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/git/refs/heads/main',\n headers: { 'Accept': 'application/vnd.github+json' },\n json: true,\n }\n);\nconst headSha = refResp.object && refResp.object.sha;\nif (!headSha) ABORT('G01', 'Could not resolve main HEAD sha');\n\nconst commitResp = await authedRequest('githubApi',\n {\n method: 'GET',\n url: 'https://api.github.com/repos/' + REPO + '/git/commits/' + headSha,\n headers: { 'Accept': 'application/vnd.github+json' },\n json: true,\n }\n);\nconst baseTreeSha = commitResp.tree && commitResp.tree.sha;\nif (!baseTreeSha) ABORT('G02', 'Could not resolve base tree sha');\n\nconst treeItems = [];\nfor (const [path, cache] of dirtyEntries) {\n const content = cache.lines.join('\\n');\n const blobResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + REPO + '/git/blobs',\n headers: { 'Accept': 'application/vnd.github+json' },\n body: { content, encoding: 'utf-8' },\n json: true,\n }\n );\n treeItems.push({ path, mode: '100644', type: 'blob', sha: blobResp.sha });\n}\nif (writeState) {\n const stateBlob = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + REPO + '/git/blobs',\n headers: { 'Accept': 'application/vnd.github+json' },\n body: { content: stateJson, encoding: 'utf-8' },\n json: true,\n }\n );\n treeItems.push({ path: STATE_PATH, mode: '100644', type: 'blob', sha: stateBlob.sha });\n}\n\nconst treeResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + REPO + '/git/trees',\n headers: { 'Accept': 'application/vnd.github+json' },\n body: { base_tree: baseTreeSha, tree: treeItems },\n json: true,\n }\n);\n\n// BUG FIX 5: [bot:W3] prefix to prevent echo loops\nconst commitMsg = '[bot:W3] notion-sync: ' + [\n applied.flipDone ? (applied.flipDone + ' done') : '',\n applied.editLine ? (applied.editLine + ' edit') : '',\n applied.createLine ? (applied.createLine + ' new') : '',\n applied.archiveFlip ? (applied.archiveFlip + ' archive') : '',\n].filter(Boolean).join(', ');\n\nconst newCommitResp = await authedRequest('githubApi',\n {\n method: 'POST',\n url: 'https://api.github.com/repos/' + REPO + '/git/commits',\n headers: { 'Accept': 'application/vnd.github+json' },\n body: { message: commitMsg, tree: treeResp.sha, parents: [headSha] },\n json: true,\n }\n);\n\nawait authedRequest('githubApi',\n {\n method: 'PATCH',\n url: 'https://api.github.com/repos/' + REPO + '/git/refs/heads/main',\n headers: { 'Accept': 'application/vnd.github+json' },\n body: { sha: newCommitResp.sha, force: false },\n json: true,\n }\n);\n\n// ---------- step 7: back-populate Source File + Hash on newly-created Notion pages ----------\nconst backPopulated = [];\nfor (const c of createdPages) {\n try {\n await authedRequest('notionApi',\n {\n method: 'PATCH',\n url: 'https://api.notion.com/v1/pages/' + c.pageId,\n headers: { 'Notion-Version': NOTION_VERSION, 'Content-Type': 'application/json' },\n body: {\n properties: {\n 'Source File': { rich_text: [{ type: 'text', text: { content: c.sourceFile } }] },\n 'Hash': { rich_text: [{ type: 'text', text: { content: c.hash } }] },\n 'Last Synced': { date: { start: nowIso } },\n },\n },\n json: true,\n }\n );\n backPopulated.push(c.pageId);\n } catch (e) {\n applied.missed.push({ kind: 'backPopulate', pageId: c.pageId, reason: String(e && e.message || e) });\n }\n}\n\nreturn [{\n json: {\n ok: true, tick: nowIso,\n totalNotionRows: rows.length,\n tracked: Object.keys(syncState.mappings).length,\n applied, backPopulated,\n commit: newCommitResp.sha,\n message: commitMsg,\n },\n}];\n\n// BUG FIX 3: top-level try/catch wrapper — no auth header leakage\n} catch (e) {\n const safe = { message: String(e && e.message || e), name: (e && e.name) || 'Error' };\n return [{ json: { error: safe, failed: true, timestamp: new Date().toISOString() } }];\n}\n" - } - } - ], - "connections": { - "Every 15 Minutes": { - "main": [ - [ - { - "node": "Notion Bidirectional Sync", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "settings": { - "executionOrder": "v1", - "availableInMCP": true, - "callerPolicy": "workflowsFromSameOwner" - } -}