@ssheleg/agent-sync 1.17.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,76 @@
1
+ ## v1.18.1 — the coordination skill publishes its own trust surface
2
+
3
+ A root skill card now states the credential, backend and enforcement boundaries.
4
+ Portable trigger and behavioral evals cover leases, id reservation and the
5
+ submodule finish protocol, while recording no model score that was not run. The
6
+ README's first viewport is installable, CI adds the pinned house audit and eval
7
+ plant, and the generated social preview is committed at 1200×630.
8
+ The open Bash-guard gap is now `AS-09`, removing its collision with the already
9
+ closed token-budget row `AS-06`.
10
+
11
+ ## v1.18.0 — a third backend, and two people who can finally see each other
12
+
13
+ `fs` keeps the record on one machine and `outline` costs $10 a month for a team; between
14
+ them sat the case this release is for — **two people, two machines, one plane, nothing to
15
+ pay**. `notion` is that plane. The container is a page, every log is a child page of it,
16
+ and every line is one paragraph block appended by `PATCH /v1/blocks/{id}/children`, which
17
+ is a server-side append with no read-modify-write anywhere in the class.
18
+
19
+ **The capability flags are earned, and the earning found two defects.**
20
+ `test/notion_live_test.py` ran against a live workspace on 2026-08-25 and passed on the
21
+ fourth attempt: `200 appends from two processes, 200 lines on one page`, `two reads return
22
+ one order`, `tree.ensure is idempotent`, `both fresh shards enumerated, 200 lines across
23
+ them`, and the forced degradation. The three runs before it are the reason to trust the
24
+ fourth. Run two went red at 171 lines with a dead writer: the retry path had never executed
25
+ in run one, and it turned out `TimeoutError` is an `OSError` rather than a `URLError`, so a
26
+ read that timed out **after** the connection was established fell past both retry branches
27
+ into the catch-all and failed permanently. Run three went red differently — 100 lines, all
28
+ from one writer, both exiting 0 — because the two writers each called `tree.ensure` on a
29
+ shared title and the check-then-create raced into two pages of that name. That one is a
30
+ finding about the measurement: `Sync.log_id()` says *this run's OWN shard, one writer per
31
+ document, always*, so the protocol never creates it. The contention case is now handed the
32
+ page id, and the case the sharded design actually depends on took its place — two fresh
33
+ shards, both enumerated, which is precisely what Outline's search index could not do.
34
+
35
+ The suite covers both halves of the retry loop now. The success half had never existed:
36
+ every assertion drove a transport that always failed, so nothing measured whether a retry
37
+ can succeed — a loop that cannot is a loop that only fails more slowly.
38
+
39
+ It needs credentials and a network, so it stays outside `npm test` and **says SKIP loudly**
40
+ rather than passing quietly where it cannot run. `exclusiveLease` stays false and no
41
+ measurement can move it: Notion has no compare-and-swap, and the endpoint's own
42
+ `conflict_error` is a retry hint rather than an arbiter.
43
+
44
+ **One registry of backends, because three copies of a two-item list is how a backend comes
45
+ to half-exist.** `CLOUD_ADAPTERS` is the single home; `init`'s argument parser, `check`'s
46
+ known-adapter test and `bootstrap`'s dispatch all read it. `bootstrap` used to build an
47
+ Outline collection whatever the project was configured for — on an `fs` project it demanded
48
+ Outline credentials for a backend that has no container at all. `check`'s credential test
49
+ now asks the adapter (`REQUIRED_ENV`, `preflight()`) instead of naming Outline's variables
50
+ in an `if`, and the Notion preflight is a real call — `GET /v1/users/me` — because "knowledge
51
+ base reachable" printed after a function that only parsed a string is the defect class this
52
+ suite exists to refuse.
53
+
54
+ **B-34's other half.** `_allocated_ids` still read `nextFreeIdPattern` directly while
55
+ `id_pattern()` beside it accepted both keys, so a config written with the modern `pattern`
56
+ key never discarded its own *Next free ID* line: `--set-baseline` stamped one above reality,
57
+ and every id at the true top read as pre-baseline and was never asked for an as-built
58
+ record. Reproduced in `fabric` on 2026-08-25, fixed here, fixture plants it back.
59
+
60
+ **`FsAdapter`'s docstring said its files are "committed and pushed"** while `check` reports a
61
+ tracked `.agent-sync/` as a problem — a committed run id hands one checkout's identity to
62
+ every clone. The check was right; the line now says so.
63
+
64
+ **And a defect in the suite itself.** `Sync()` calls `load_env_file`, which writes the
65
+ project's variables into `os.environ` — correct for the CLI, a leak in-process. Two checks
66
+ built temporary `fs` projects and left `AGENT_SYNC_BACKEND=fs` behind, and that variable
67
+ OVERRIDES the configured backend, so the next check to depend on it silently read the
68
+ previous check's project. The new dispatch check passed alone and failed in the suite, which
69
+ is the signature of this class rather than of the code under test. Every check now runs
70
+ through `_guarded`, which restores the environment and the working directory after it.
71
+
72
+ Five checks, five planted defects: 51 → 56 fixtures.
73
+
1
74
  ## v1.17.0 — the identity that had never once been established
2
75
 
3
76
  `_session_key()` falls back to one `shared` entry per checkout when it cannot tell which
package/README.md CHANGED
@@ -3,6 +3,23 @@
3
3
  [![CI](https://github.com/ssheleg/agent-sync/actions/workflows/validate.yml/badge.svg)](https://github.com/ssheleg/agent-sync/actions/workflows/validate.yml)
4
4
  [![npm](https://img.shields.io/npm/v/%40ssheleg%2Fagent-sync)](https://www.npmjs.com/package/@ssheleg/agent-sync)
5
5
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+ [![site](https://img.shields.io/badge/docs-skills.sshlg.me-8ab0ff)](https://skills.sshlg.me/skills/agent-sync/)
7
+
8
+ **Coordinate concurrent coding agents with expiring claims, race-free ids and a durable run journal.**
9
+
10
+ ```bash
11
+ npx skills add ssheleg/agent-sync
12
+ ```
13
+
14
+ Ask: `Set up leases before three agents edit this repository.`
15
+
16
+ **[Detailed docs →](https://skills.sshlg.me/skills/agent-sync/)**
17
+
18
+ **[Docs, and every skill →](https://skills.sshlg.me/)** · [this skill's page](https://skills.sshlg.me/skills/agent-sync/) · [follow @sshlg93 on X](https://x.com/intent/follow?screen_name=sshlg93)
19
+
20
+ Loads in **DeepSeek Harness** (`dsh`) with **no plugin to write**: it reads the
21
+ Agent Skills standard directly, scanning `~/.agents/skills` — where `npx skills
22
+ add` puts this pack — at rank 500.
6
23
 
7
24
  **Several coding agents, one repository, no collisions — and each one can see what the
8
25
  others are doing.**
@@ -455,7 +472,7 @@ npm test # both of the above
455
472
  ```
456
473
 
457
474
  What ships: one skill (`agent-sync`), `scripts/agent_sync.py` (stdlib only), four hook
458
- scripts, the slash command, `agent-sync.schema.json`, and ten reference contracts the
475
+ scripts, the slash command, `agent-sync.schema.json`, and eleven reference contracts the
459
476
  agent loads on their own trigger rather than by default:
460
477
 
461
478
  | Reference | Read it when |
@@ -463,6 +480,7 @@ agent loads on their own trigger rather than by default:
463
480
  | [`adapter-contract.md`](plugins/agent-sync/skills/agent-sync/references/adapter-contract.md) | adding or auditing a knowledge backend |
464
481
  | [`lease-protocol.md`](plugins/agent-sync/skills/agent-sync/references/lease-protocol.md) | changing acquisition, expiry, stealing or id allocation |
465
482
  | [`backend-outline.md`](plugins/agent-sync/skills/agent-sync/references/backend-outline.md) | making any Outline API call, or debugging one |
483
+ | [`backend-notion.md`](plugins/agent-sync/skills/agent-sync/references/backend-notion.md) | making any Notion API call, or debugging one |
466
484
  | [`backend-fs.md`](plugins/agent-sync/skills/agent-sync/references/backend-fs.md) | running without a cloud backend, or explaining degraded mode |
467
485
  | [`pipeline-binding.md`](plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md) | wiring `pipeline.json`, or adding a stage hook |
468
486
  | [`hooks.md`](plugins/agent-sync/skills/agent-sync/references/hooks.md) | installing, debugging or removing the Claude Code hooks |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
4
4
  "description": "Let concurrent coding agents share one project without colliding — leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.17.0",
4
+ "version": "1.18.1",
5
5
  "description": "Coordination layer for multi-agent repositories — leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
6
6
  "author": {
7
7
  "name": "ssheleg",
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.17.0"
7
+ version: "1.18.1"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -99,22 +99,25 @@ question gets asked and answered, once, and written down.
99
99
  **Ask the operator these two things in chat — do not guess, do not pick a default:**
100
100
 
101
101
  1. **Where should coordination state live?**
102
- - a knowledge cloud (`outline`) the shared record, awareness and board across
103
- machines. **It does not decide leases**; nothing in it can (trap 1);
102
+ - a knowledge cloud `outline`, hosted or self-hosted, or `notion` the shared
103
+ record, awareness and board across machines. **Neither decides leases**; nothing
104
+ in either can (trap 1);
104
105
  - or local files (`fs`) — no credentials, and no visibility to an agent on another
105
106
  machine: no shared awareness, no cross-repo signals, no shared board.
106
107
 
107
108
  The lease is decided separately by `leaseBackend` (trap 2), and **`gated` follows that
108
109
  choice, never the record plane**. Report the guarantee you actually have, not the
109
110
  stronger one the record plane suggests.
110
- 2. **If cloud: the instance URL.** The URL is configuration, not a secret, so you
111
- may write it. The **token is not** you never ask for it in chat, never read it
112
- back, and never place it yourself.
111
+ 2. **If cloud: where.** Outline needs its instance URL; Notion needs the id of the page
112
+ the container goes under. Both are configuration, not secrets, so you may write them.
113
+ The **token is not** — you never ask for it in chat, never read it back, and never
114
+ place it yourself.
113
115
 
114
116
  Then run it with their answers:
115
117
 
116
118
  ```bash
117
119
  python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend outline --url https://<their-instance>
120
+ python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend notion
118
121
  python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend fs
119
122
  ```
120
123
 
@@ -321,7 +324,7 @@ the parent; a service repo listing the parent's decision register is a config de
321
324
  - Credentials never reach `argv`, a log line, or the repository.
322
325
  - Degrade out loud. `ungated` is an acceptable state; a false claim of enforcement is not.
323
326
  - Two agents in one checkout are two identities, or the lease is decoration.
324
- - A submodule commit is unfinished until the parent points at it — `finish` before you call it done.
327
+ - A submodule commit is unfinished until the parent points at it — run `finish`.
325
328
  - Everything the cloud holds about a durable fact is a link to git, never a substitute.
326
329
 
327
330
  ## References
@@ -333,6 +336,7 @@ Each file is loaded on its own trigger, not by default.
333
336
  | `references/adapter-contract.md` | adding or auditing a knowledge backend — six primitives, the capability flags, an honest degradation path |
334
337
  | `references/lease-protocol.md` | changing acquisition, expiry, stealing or id allocation |
335
338
  | `references/backend-outline.md` | making any Outline API call, or debugging one |
339
+ | `references/backend-notion.md` | making any Notion API call, or debugging one |
336
340
  | `references/backend-fs.md` | running without a cloud backend, or explaining degraded mode |
337
341
  | `references/pipeline-binding.md` | wiring `pipeline.json`, or adding a stage hook |
338
342
  | `references/hooks.md` | installing, debugging or removing the Claude Code hooks |
@@ -0,0 +1,153 @@
1
+ # Notion backend
2
+
3
+ **Read this when** making any Notion API call or debugging one.
4
+
5
+ ## Contents
6
+
7
+ - [Shape of the API](#shape-of-the-api)
8
+ - [Capabilities](#capabilities)
9
+ - [Primitive mapping](#primitive-mapping)
10
+ - [Why generated pages are one code block](#why-generated-pages-are-one-code-block)
11
+ - [Calling it without leaking the token](#calling-it-without-leaking-the-token)
12
+ - [Rate limits](#rate-limits)
13
+ - [Getting a token](#getting-a-token)
14
+ - [Sharing the plane with another person](#sharing-the-plane-with-another-person)
15
+ - [Verifying access](#verifying-access)
16
+
17
+
18
+ [Notion](https://www.notion.so) is single-tenant SaaS. There is no instance URL to
19
+ configure and none to leak: every client talks to the same host, which is why
20
+ `api.notion.com` is a constant in this code and Outline's address is not.
21
+
22
+ ## Shape of the API
23
+
24
+ - Base: `https://api.notion.com/v1`. Methods are the ordinary ones — `GET` reads,
25
+ `POST` creates, `PATCH` appends and updates, `DELETE` removes.
26
+ - `Authorization: Bearer <token>`, `Content-Type: application/json`, and
27
+ **`Notion-Version` is required**. This adapter pins `2026-03-11`; an unpinned client
28
+ gets whatever is current on the morning a breaking change ships.
29
+ - Errors are unenveloped: the HTTP status is the status, and the body carries
30
+ `{"code": "...", "message": "..."}`. The codes that matter here are
31
+ `rate_limited` (429), `service_overload` (529) and **`conflict_error` (409)**.
32
+
33
+ ## Capabilities
34
+
35
+ ```json
36
+ { "atomicAppend": true, "totalOrderRead": true, "search": true }
37
+ ```
38
+
39
+ `atomicAppend` is true because `PATCH /v1/blocks/{id}/children` is documented as
40
+ "creates and appends new children blocks to the parent block_id specified", appended
41
+ "to the end of the parent block's children" by default. There is no read-modify-write
42
+ anywhere in the adapter, which is the whole of what the flag claims.
43
+
44
+ **`exclusiveLease` is false, and no measurement can move it.** Notion has no
45
+ compare-and-swap: nothing in the API lets a writer say *only if the page is still at
46
+ the revision I read*. The endpoint documents `conflict_error` for two writers
47
+ colliding, which is a retry hint, not an arbiter. Exclusion stays with `leaseBackend`
48
+ — see `lease-protocol.md`.
49
+
50
+ **These flags come from a measurement, not from the paragraph above.**
51
+ `test/notion_live_test.py` is that measurement, and it passed against a live workspace on
52
+ 2026-08-25: 200 appends from two processes left 200 lines on one page, two independent
53
+ reads returned one order, and two freshly created shards were both enumerated. Re-run it
54
+ after any change to this adapter. The equivalent trap on Outline returned twelve successes
55
+ for twelve concurrent appends and left three lines, which is why this is measured rather
56
+ than read off the endpoint's documentation.
57
+
58
+ **One thing the measurement is not allowed to test**, because the protocol never does it:
59
+ two runs calling `tree.ensure` on the SAME title at once. The check-then-create races and
60
+ both win a page of that name — observed, 100 lines on one and 100 on the other, both
61
+ writers exiting 0. `Sync.log_id()` gives every run its own shard for exactly this class of
62
+ reason, so the contention case is handed a page id instead.
63
+
64
+ ## Primitive mapping
65
+
66
+ | Primitive | Call | Body |
67
+ |---|---|---|
68
+ | `tree.ensure` (container) | `POST /v1/pages` | `{parent: {page_id}, properties: {title}}` — `bootstrap` creates it once |
69
+ | `tree.ensure` (document) | list children, else `POST /v1/pages` | matched on `child_page.title`, never on search |
70
+ | `log.append` | `PATCH /v1/blocks/{id}/children` | one `paragraph` block per line |
71
+ | `log.read` | `GET /v1/blocks/{id}/children` | cursor-paginated; join each block's text with a newline |
72
+ | `doc.put` | `PATCH /v1/blocks/{block_id}` | one `code` block, replaced wholesale |
73
+ | `doc.get` | `GET /v1/blocks/{id}/children` | the `code` block's text |
74
+ | `search` | `POST /v1/search` | `{query, page_size}` |
75
+
76
+ **Shards are enumerated by structure, never by search.** Notion's search index is
77
+ eventually consistent, so a page it has not indexed yet reads as a page that does not
78
+ exist — and a run that cannot see the other shards replays only its own and concludes
79
+ it won. Outline paid for this lesson with eight processes and eight winners; the
80
+ listing endpoint returns a page the moment it is created.
81
+
82
+ Two hard limits: **100 children per append request**, and **2000 characters per
83
+ rich-text item**. Lines are chunked below the second, at 1900.
84
+
85
+ ## Why generated pages are one code block
86
+
87
+ The obvious `doc.put` — delete every child, append the new ones — costs one request
88
+ per existing block. At three requests a second a regenerated board takes minutes, and
89
+ the board is regenerated often. A single `code` block is two requests whatever the
90
+ size, and it also stops the Notion editor from reinterpreting generated markdown as
91
+ Notion formatting.
92
+
93
+ ## Calling it without leaking the token
94
+
95
+ `curl -H "Authorization: Bearer $TOKEN"` puts the credential in `argv`, where every
96
+ other process on the machine can read it. Use a config file on stdin — `--config -`
97
+ reads the whole request description from a heredoc, so neither the token nor the
98
+ payload appears in the process table.
99
+
100
+ **Prefer the bundled `scripts/agent_sync.py`.** It calls the API through `urllib`
101
+ inside its own process — no subprocess, no `argv`, nothing for another process to read.
102
+
103
+ ## Rate limits
104
+
105
+ Two of them, and the second is the one that surprises people:
106
+
107
+ - **Per connection** — an average of three requests per second, with some burst.
108
+ - **Per workspace** — shared across every connection in it, scaled to the plan.
109
+
110
+ Either returns `rate_limited` / 429 with `additional_data.rate_limit_reason` naming
111
+ which one; 529 `service_overload` means Notion itself is having a minute. Both carry
112
+ `Retry-After` in integer seconds. Honour it, back off exponentially, at most five
113
+ attempts, then fail loudly. Do not spin.
114
+
115
+ Because the second limit is shared, **a rate limit here is not necessarily your own
116
+ traffic** — another script or another person in the same workspace can spend it. The
117
+ adapter says so in its message rather than blaming the caller.
118
+
119
+ ## Getting a token
120
+
121
+ A personal access token from Notion's developer portal — Personal access tokens →
122
+ New token. It begins `ntn_`. No integration and no page-sharing dance is required:
123
+ the token acts as the person who made it.
124
+
125
+ ```
126
+ AGENT_SYNC_NOTION_TOKEN=<created by the operator, pasted by the operator>
127
+ AGENT_SYNC_NOTION_PARENT=<the 32-character id ending the parent page's URL>
128
+ AGENT_SYNC_NOTION_COLLECTION=<container page id, printed by `bootstrap`>
129
+ ```
130
+
131
+ The operator creates it and pastes it themselves. Do not ask for the value in chat,
132
+ do not read it back, and do not write it anywhere in the repository.
133
+
134
+ ## Sharing the plane with another person
135
+
136
+ This is the reason the backend exists. Each person uses **their own** token; what
137
+ they share is the container page. Share it in Notion the ordinary way, and every
138
+ holder of a token that can see that page reads the same plane.
139
+
140
+ What that does **not** give you is a shared lease: the plane carries the record and
141
+ the awareness, and `leaseBackend` decides exclusion. Two people on two machines want
142
+ `leaseBackend: "git"`, whose non-fast-forward rejection is a real compare-and-swap.
143
+
144
+ ## Verifying access
145
+
146
+ ```bash
147
+ python3 scripts/agent_sync.py check
148
+ ```
149
+
150
+ The preflight calls `GET /v1/users/me` — one request, no writes — and then resolves
151
+ the container id. A `401` means the token is wrong or revoked, and retrying with the
152
+ same one will not help. An `object_not_found` on the container means the token cannot
153
+ see that page: share the page with the person whose token it is.
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- VERSION = "1.17.0"
36
+ VERSION = "1.18.1"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -489,6 +489,23 @@ class Adapter:
489
489
  name = "none"
490
490
  capabilities = {"atomicAppend": False, "totalOrderRead": False, "search": False,
491
491
  "exclusiveLease": False}
492
+ # What must be in the environment before this adapter can reach anything, and the
493
+ # variable holding its container id. Declared here so `check` and `status` ask the
494
+ # adapter instead of carrying a per-backend branch each — the shape that let the
495
+ # Outline credential check live in one place and be missing from the other.
496
+ REQUIRED_ENV: tuple[str, ...] = ()
497
+ COLLECTION_ENV = ""
498
+
499
+ def preflight(self) -> str:
500
+ """One cheap live call proving the credential works, or "" for none.
501
+
502
+ `check` used to report "knowledge base reachable" after a call that only
503
+ parsed a string. A check that reports reachability without reaching is the
504
+ defect class this whole suite exists to refuse."""
505
+ return ""
506
+
507
+ def create_container(self, name: str) -> str:
508
+ raise Fail(f"backend '{self.name}' has no container to create")
492
509
 
493
510
  def configured(self) -> bool:
494
511
  raise NotImplementedError
@@ -531,6 +548,8 @@ class OutlineAdapter(Adapter):
531
548
  """
532
549
 
533
550
  name = "outline"
551
+ REQUIRED_ENV = ("AGENT_SYNC_OUTLINE_URL", "AGENT_SYNC_OUTLINE_TOKEN")
552
+ COLLECTION_ENV = "AGENT_SYNC_OUTLINE_COLLECTION"
534
553
  # exclusiveLease is FALSE and that is not a formality. Outline has no
535
554
  # compare-and-swap, so a decision cannot be made after all contenders have
536
555
  # written — only after a settle window that is long enough in practice. Two runs
@@ -597,6 +616,16 @@ class OutlineAdapter(Adapter):
597
616
  raise Fail(f"outline {endpoint}: cannot reach the instance ({exc.reason})") from exc
598
617
  raise Fail(f"outline {endpoint}: gave up after 7 attempts")
599
618
 
619
+ def preflight(self) -> str:
620
+ self.resolve_collection()
621
+ return "knowledge base reachable and the collection resolves"
622
+
623
+ def create_container(self, name: str) -> str:
624
+ data = self._call("collections.create", {"name": name, "description":
625
+ "Coordination plane for agent-sync. Generated pages "
626
+ "are stamped; edit sources in git."})
627
+ return str(data["id"])
628
+
600
629
  def resolve_collection(self) -> str:
601
630
  """Accept a UUID, a urlId, or the whole `name-urlId` slug from the browser.
602
631
 
@@ -698,7 +727,11 @@ class OutlineAdapter(Adapter):
698
727
 
699
728
 
700
729
  class FsAdapter(Adapter):
701
- """Degraded mode. Files under .agent-sync/, committed and pushed.
730
+ """Degraded mode. Files under .agent-sync/, and NOT committed.
731
+
732
+ `check` refuses a tracked state directory — a committed run id hands this
733
+ checkout's identity to every clone, and the tree is dirty after every tool call.
734
+ This line claimed the opposite until 2026-08-25; the check was right.
702
735
 
703
736
  atomicAppend is FALSE on purpose: agents here are separated by git, not by a
704
737
  filesystem, so ordering is decided by a merge after the fact — which is not
@@ -767,10 +800,295 @@ class FsAdapter(Adapter):
767
800
  return [str(q) for q in sorted(self.base.glob(f"{stem}*.md"))]
768
801
 
769
802
 
803
+ class NotionAdapter(Adapter):
804
+ """Notion. The container is a page, every log is a child page of it, and every
805
+ line is one paragraph block appended server-side.
806
+
807
+ The append is a real append: `PATCH /v1/blocks/{id}/children` "creates and appends
808
+ new children blocks to the parent block_id specified", at the end by default. There
809
+ is no read-modify-write anywhere in this class, which is what `atomicAppend` claims
810
+ and the only thing it claims.
811
+
812
+ `exclusiveLease` is FALSE and no measurement can change it: Notion has no
813
+ compare-and-swap. Two appends racing on one page can both succeed — the endpoint
814
+ documents `conflict_error` (409) for the case where they collide instead — so a
815
+ decision made by replaying this log is a decision made after the fact. Exclusion
816
+ stays with `leaseBackend`.
817
+ """
818
+
819
+ name = "notion"
820
+ REQUIRED_ENV = ("AGENT_SYNC_NOTION_TOKEN",)
821
+ COLLECTION_ENV = "AGENT_SYNC_NOTION_COLLECTION"
822
+ API = "https://api.notion.com/v1"
823
+ # Pinned, not floating. Notion versions its API by date and an unpinned client
824
+ # gets whatever is current the morning a breaking change ships.
825
+ NOTION_VERSION = "2026-03-11"
826
+ # One rich_text item caps at 2000 characters. Chunk below it rather than at it:
827
+ # a line that lands exactly on the boundary is the one that fails in production.
828
+ CHUNK = 1900
829
+ # The endpoint's own cap on children per request.
830
+ BATCH = 100
831
+
832
+ capabilities = {"atomicAppend": True, "totalOrderRead": True, "search": True,
833
+ "exclusiveLease": False}
834
+
835
+ def __init__(self) -> None:
836
+ self.token = os.environ.get("AGENT_SYNC_NOTION_TOKEN") or ""
837
+ self.parent = os.environ.get("AGENT_SYNC_NOTION_PARENT") or ""
838
+ self.collection = os.environ.get("AGENT_SYNC_NOTION_COLLECTION") or ""
839
+ self._ids: dict[str, str] = {}
840
+
841
+ def configured(self) -> bool:
842
+ return bool(self.token and (self.collection or self.parent))
843
+
844
+ # -- transport ---------------------------------------------------------
845
+
846
+ @staticmethod
847
+ def _uuid(value: str) -> str:
848
+ """Accept what a person copies out of the address bar.
849
+
850
+ A Notion URL ends in 32 undashed hex characters; the API takes either form,
851
+ but a slug like `Board-1f2e…` does not resolve, so the digits are extracted
852
+ rather than assumed to be the whole string."""
853
+ raw = re.sub(r"[^0-9a-fA-F]", "", (value or "").rsplit("/", 1)[-1])
854
+ if len(raw) < 32:
855
+ raise Fail(f"'{value}' does not contain a 32-character Notion id — copy the "
856
+ "page's URL and use the id at its end")
857
+ raw = raw[-32:].lower()
858
+ return f"{raw[0:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:32]}"
859
+
860
+ def _call(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
861
+ if not self.configured():
862
+ raise Fail("Notion is not configured (AGENT_SYNC_NOTION_TOKEN missing from "
863
+ "the environment, or neither _COLLECTION nor _PARENT is set)")
864
+ data = json.dumps(body).encode() if body is not None else None
865
+ req = urllib.request.Request(f"{self.API}/{path}", data=data, method=method)
866
+ # The token reaches a header and nothing else — never a command line, never a
867
+ # log line, and never this module's error text.
868
+ req.add_header("Authorization", f"Bearer {self.token}")
869
+ req.add_header("Notion-Version", self.NOTION_VERSION)
870
+ req.add_header("Accept", "application/json")
871
+ if data is not None:
872
+ req.add_header("Content-Type", "application/json")
873
+
874
+ delay = 1.0
875
+ # Five attempts on a rate limit, then fail loudly — the contract's number,
876
+ # not a guess.
877
+ for attempt in range(5):
878
+ try:
879
+ with urllib.request.urlopen(req, timeout=20) as resp:
880
+ return json.loads(resp.read().decode() or "{}")
881
+ except urllib.error.HTTPError as exc:
882
+ detail, code = "", ""
883
+ try:
884
+ payload = json.loads(exc.read().decode())
885
+ detail = payload.get("message") or ""
886
+ code = payload.get("code") or ""
887
+ except (ValueError, OSError):
888
+ pass
889
+ if exc.code in (401, 403):
890
+ raise Fail(
891
+ f"notion {method} {path}: {exc.code} — the token is rejected"
892
+ f"{': ' + detail if detail else ''}. A credential does not "
893
+ "become valid on retry.") from exc
894
+ # 429 and 529 carry Retry-After in integer seconds and mean *wait*.
895
+ # 409 conflict_error is two writers touching one page — the same
896
+ # answer, on a shorter clock. 5xx is the server's own bad minute.
897
+ if exc.code in (409, 429, 500, 502, 503, 504, 529) and attempt < 4:
898
+ hint = exc.headers.get("Retry-After")
899
+ time.sleep((float(hint) if hint else delay) + random.random() * 0.4)
900
+ delay *= 2
901
+ continue
902
+ reason = f"{code}: {detail}" if code and detail else (detail or code)
903
+ raise Fail(f"notion {method} {path}: HTTP {exc.code}"
904
+ f"{' — ' + reason if reason else ''}") from exc
905
+ except urllib.error.URLError as exc:
906
+ if attempt < 2:
907
+ time.sleep(delay)
908
+ delay *= 2
909
+ continue
910
+ raise Fail(f"notion {method} {path}: cannot reach the API "
911
+ f"({exc.reason})") from exc
912
+ except TimeoutError as exc:
913
+ # A read that times out AFTER the connection is established raises this
914
+ # directly — `URLError` wraps only the connect failure — so it used to
915
+ # fall into the catch-all below and fail permanently on the first slow
916
+ # response. The contract calls a transport error retryable, and this is
917
+ # one. Measured 2026-08-25: a writer died mid-run with "The read
918
+ # operation timed out" while the workspace was under its own rate limit.
919
+ if attempt < 2:
920
+ time.sleep(delay)
921
+ delay *= 2
922
+ continue
923
+ raise Fail(f"notion {method} {path}: the API did not answer within "
924
+ f"{20}s, {attempt + 1} times over") from exc
925
+ except (ValueError, OSError) as exc:
926
+ raise Fail(f"notion {method} {path}: {exc}") from exc
927
+ raise Fail(f"notion {method} {path}: rate limited through 5 attempts — the "
928
+ "workspace limit is shared by every connection in it, so this is "
929
+ "not necessarily your own traffic")
930
+
931
+ # -- containers --------------------------------------------------------
932
+
933
+ def preflight(self) -> str:
934
+ self._call("GET", "users/me")
935
+ self.resolve_collection()
936
+ return "token accepted by Notion and the container id resolves"
937
+
938
+ def create_container(self, name: str) -> str:
939
+ if not self.parent:
940
+ raise Fail("AGENT_SYNC_NOTION_PARENT is not set — put the id of the page "
941
+ "the container should live under in it. Copy the 32-character "
942
+ "id from the end of that page's URL.")
943
+ page = self._call("POST", "pages", {
944
+ "parent": {"type": "page_id", "page_id": self._uuid(self.parent)},
945
+ "properties": {"title": {"title": [{"type": "text",
946
+ "text": {"content": name[:2000]}}]}},
947
+ })
948
+ return str(page["id"])
949
+
950
+ def resolve_collection(self) -> str:
951
+ if not self.collection:
952
+ raise Fail("AGENT_SYNC_NOTION_COLLECTION is not set — run `bootstrap` to "
953
+ "create the container page, then put the id it prints into "
954
+ f"{ENV_FILE}")
955
+ return self._uuid(self.collection)
956
+
957
+ def _children(self, oid: str) -> list[dict[str, Any]]:
958
+ """Every child block, followed to the last page.
959
+
960
+ Stopping at the first page would make a long log read as a short one, and a
961
+ short log replays into a wrong answer rather than an error."""
962
+ out: list[dict[str, Any]] = []
963
+ cursor = ""
964
+ while True:
965
+ q = f"blocks/{oid}/children?page_size=100" + (f"&start_cursor={cursor}" if cursor else "")
966
+ data = self._call("GET", q)
967
+ out.extend(data.get("results") or [])
968
+ if not data.get("has_more"):
969
+ return out
970
+ cursor = data.get("next_cursor") or ""
971
+ if not cursor:
972
+ return out
973
+
974
+ def tree_ensure(self, path: str) -> str:
975
+ if path in self._ids:
976
+ return self._ids[path]
977
+ collection = self.resolve_collection()
978
+ # Enumerate by STRUCTURE, never by the search index — Notion's search is
979
+ # eventually consistent, and a shard it has not indexed yet reads as a shard
980
+ # that does not exist. That is how eight processes each see only their own
981
+ # log and each conclude they won.
982
+ for block in self._children(collection):
983
+ if block.get("type") == "child_page" \
984
+ and (block.get("child_page") or {}).get("title") == path:
985
+ self._ids[path] = block["id"]
986
+ return str(block["id"])
987
+ page = self._call("POST", "pages", {
988
+ "parent": {"type": "page_id", "page_id": collection},
989
+ "properties": {"title": {"title": [{"type": "text",
990
+ "text": {"content": path[:2000]}}]}},
991
+ })
992
+ self._ids[path] = page["id"]
993
+ return str(page["id"])
994
+
995
+ # -- logs --------------------------------------------------------------
996
+
997
+ def _rich(self, text: str) -> list[dict[str, Any]]:
998
+ parts = [text[i:i + self.CHUNK] for i in range(0, len(text), self.CHUNK)] or [""]
999
+ return [{"type": "text", "text": {"content": p}} for p in parts]
1000
+
1001
+ @staticmethod
1002
+ def _text_of(block: dict[str, Any]) -> str | None:
1003
+ """The literal text of a block, or None when it carries none.
1004
+
1005
+ `None` and `""` are different answers here: an empty paragraph is a line, and
1006
+ a divider is not, and folding the two loses a line every time somebody presses
1007
+ enter in the Notion editor."""
1008
+ body = block.get(block.get("type") or "") or {}
1009
+ rich = body.get("rich_text")
1010
+ if not isinstance(rich, list):
1011
+ return None
1012
+ return "".join(r.get("plain_text") or (r.get("text") or {}).get("content") or ""
1013
+ for r in rich)
1014
+
1015
+ def log_append(self, oid: str, line: str) -> None:
1016
+ self._call("PATCH", f"blocks/{oid}/children", {"children": [
1017
+ {"object": "block", "type": "paragraph",
1018
+ "paragraph": {"rich_text": self._rich(line.rstrip("\n"))}}]})
1019
+
1020
+ def log_read(self, oid: str) -> str:
1021
+ lines = [t for t in (self._text_of(b) for b in self._children(oid)) if t is not None]
1022
+ return "\n".join(lines) + ("\n" if lines else "")
1023
+
1024
+ # -- generated documents ----------------------------------------------
1025
+
1026
+ def doc_put(self, oid: str, text: str) -> None:
1027
+ """Generated pages only, and they are ONE code block on purpose.
1028
+
1029
+ The obvious implementation — delete every child, append the new ones — costs
1030
+ one request per existing block, and at three requests a second a regenerated
1031
+ board would take minutes. A single block is two requests whatever the size."""
1032
+ blocks = self._children(oid)
1033
+ target = next((b for b in blocks if b.get("type") == "code"), None)
1034
+ payload = {"rich_text": self._rich(text), "language": "markdown"}
1035
+ if target:
1036
+ self._call("PATCH", f"blocks/{target['id']}", {"code": payload})
1037
+ return
1038
+ for block in blocks:
1039
+ self._call("DELETE", f"blocks/{block['id']}")
1040
+ self._call("PATCH", f"blocks/{oid}/children",
1041
+ {"children": [{"object": "block", "type": "code", "code": payload}]})
1042
+
1043
+ def doc_get(self, oid: str) -> str:
1044
+ blocks = self._children(oid)
1045
+ target = next((b for b in blocks if b.get("type") == "code"), None)
1046
+ if target is not None:
1047
+ return self._text_of(target) or ""
1048
+ return self.log_read(oid)
1049
+
1050
+ # -- search ------------------------------------------------------------
1051
+
1052
+ def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
1053
+ data = self._call("POST", "search", {"query": query, "page_size": max(1, min(limit, 100))})
1054
+ out: list[dict[str, Any]] = []
1055
+ for row in (data.get("results") or [])[:limit]:
1056
+ title = ""
1057
+ for prop in (row.get("properties") or {}).values():
1058
+ if isinstance(prop, dict) and prop.get("type") == "title":
1059
+ title = "".join(t.get("plain_text") or "" for t in (prop.get("title") or []))
1060
+ break
1061
+ out.append({"id": row.get("id"), "title": title, "snippet": ""})
1062
+ return out
1063
+
1064
+ def log_shards(self, prefix: str) -> list[str]:
1065
+ out: list[str] = []
1066
+ for block in self._children(self.resolve_collection()):
1067
+ if block.get("type") != "child_page":
1068
+ continue
1069
+ title = (block.get("child_page") or {}).get("title") or ""
1070
+ if title.startswith(prefix):
1071
+ out.append(str(block["id"]))
1072
+ self._ids[title] = block["id"]
1073
+ return out
1074
+
1075
+
1076
+ # Every backend that talks to a service, in ONE place. `check`, `init`'s argument
1077
+ # parser, `bootstrap` and `status` all read this dict rather than each carrying a copy
1078
+ # of the list — the copies are what drift, and a backend missing from one of them is a
1079
+ # backend that half exists.
1080
+ CLOUD_ADAPTERS: dict[str, type[Adapter]] = {
1081
+ "outline": OutlineAdapter,
1082
+ "notion": NotionAdapter,
1083
+ }
1084
+ BACKENDS = tuple(sorted(CLOUD_ADAPTERS)) + ("fs",)
1085
+
1086
+
770
1087
  def make_adapter(cfg: dict[str, Any], root: Path) -> Adapter:
771
1088
  backend = os.environ.get("AGENT_SYNC_BACKEND") or cfg.get("backend") or "fs"
772
- if backend == "outline":
773
- ad = OutlineAdapter()
1089
+ cls = CLOUD_ADAPTERS.get(backend)
1090
+ if cls is not None:
1091
+ ad = cls()
774
1092
  if not ad.configured():
775
1093
  return FsAdapter(root)
776
1094
  return ad
@@ -2374,7 +2692,11 @@ class Sync:
2374
2692
  return set()
2375
2693
  text = path.read_text()
2376
2694
  ids = set(re.findall(rf"\b{reg}-\d+\b", text))
2377
- pattern = spec.get("nextFreeIdPattern")
2695
+ # Through the shim, not the raw key. Reading `nextFreeIdPattern` directly meant
2696
+ # a config written with the modern `pattern` key never discarded its own
2697
+ # next-free line, and the baseline was stamped one higher than reality —
2698
+ # exactly what the docstring above warns about. Reproduced 2026-08-25.
2699
+ pattern = id_pattern(spec)
2378
2700
  if pattern:
2379
2701
  m = re.search(pattern, text)
2380
2702
  if m:
@@ -2889,6 +3211,10 @@ def cmd_init(args: argparse.Namespace) -> int:
2889
3211
  extra = (f"AGENT_SYNC_OUTLINE_URL={args.url}\n"
2890
3212
  "AGENT_SYNC_OUTLINE_TOKEN=\n"
2891
3213
  "AGENT_SYNC_OUTLINE_COLLECTION=\n")
3214
+ elif backend == "notion":
3215
+ extra = ("AGENT_SYNC_NOTION_TOKEN=\n"
3216
+ "AGENT_SYNC_NOTION_PARENT=\n"
3217
+ "AGENT_SYNC_NOTION_COLLECTION=\n")
2892
3218
  env_path = root / ENV_FILE
2893
3219
  if env_path.exists() and not args.force:
2894
3220
  print(f"• {ENV_FILE} already exists — left untouched")
@@ -2902,7 +3228,25 @@ def cmd_init(args: argparse.Namespace) -> int:
2902
3228
  ensure_untracked(root, f"{STATE_DIR}/")
2903
3229
 
2904
3230
  print()
2905
- if backend == "outline":
3231
+ if backend == "notion":
3232
+ print("NEXT — three things only you can do:")
3233
+ print(" 1. Create a personal access token: Notion's developer portal →")
3234
+ print(" Personal access tokens → New token. It begins `ntn_`.")
3235
+ print(" Reference: https://developers.notion.com/docs/create-a-notion-integration")
3236
+ print(f" 2. Put it, and the page the container should live under, in {ENV_FILE}:")
3237
+ print(" AGENT_SYNC_NOTION_TOKEN=<paste it here>")
3238
+ print(" AGENT_SYNC_NOTION_PARENT=<the 32-character id ending that page's URL>")
3239
+ print(" 3. Load the file into your shell before running agents:")
3240
+ print(f" set -a && . ./{ENV_FILE} && set +a")
3241
+ print()
3242
+ print(" Then run `bootstrap` — it creates the container page and prints the id")
3243
+ print(" to paste into AGENT_SYNC_NOTION_COLLECTION. Share that page in Notion")
3244
+ print(" with the other people working this repository; each of them uses their")
3245
+ print(" OWN token, and yours stays yours.")
3246
+ print()
3247
+ print(" The lease is decided by `leaseBackend`, not by Notion — Notion has no")
3248
+ print(" compare-and-swap. See references/backend-notion.md.")
3249
+ elif backend == "outline":
2906
3250
  print("NEXT — two things only you can do:")
2907
3251
  print(f" 1. Create an API token in your Outline instance at {args.url}")
2908
3252
  print(" (Settings → API and access), then put it in this line of "
@@ -3005,8 +3349,8 @@ def cmd_status(_args: argparse.Namespace) -> int:
3005
3349
  elif not s.lease_is_cross_machine:
3006
3350
  print(f" ({detail})")
3007
3351
 
3008
- if ad.name == "outline" and isinstance(ad, OutlineAdapter) and not ad.collection:
3009
- print("\n✗ AGENT_SYNC_OUTLINE_COLLECTION is empty.")
3352
+ if ad.COLLECTION_ENV and not getattr(ad, "collection", ""):
3353
+ print(f"\n✗ {ad.COLLECTION_ENV} is empty.")
3010
3354
  print("\nNEXT: create the container, then paste the id into "
3011
3355
  f"{ENV_FILE}:")
3012
3356
  print(" agent_sync.py bootstrap")
@@ -3166,20 +3510,26 @@ def pipeline_installed() -> bool:
3166
3510
 
3167
3511
 
3168
3512
  def cmd_bootstrap(_args: argparse.Namespace) -> int:
3169
- load_env_file(project_root())
3170
- ad = OutlineAdapter()
3513
+ root = project_root()
3514
+ load_env_file(root)
3515
+ cfg_path = root / CONFIG_PATH
3516
+ cfg = json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
3517
+ backend = os.environ.get("AGENT_SYNC_BACKEND") or cfg.get("backend") or "fs"
3518
+ cls = CLOUD_ADAPTERS.get(backend)
3519
+ if cls is None:
3520
+ raise Fail(f"backend '{backend}' has no container to create — `bootstrap` is "
3521
+ f"for {', '.join(sorted(CLOUD_ADAPTERS))}")
3522
+ ad = cls()
3171
3523
  if not ad.configured():
3172
- raise Fail("set AGENT_SYNC_OUTLINE_URL and AGENT_SYNC_OUTLINE_TOKEN first")
3173
- if ad.collection:
3174
- print(f"collection already set: {ad.collection}")
3524
+ raise Fail(f"set {' and '.join(cls.REQUIRED_ENV)} first")
3525
+ if getattr(ad, "collection", ""):
3526
+ print(f"container already set: {ad.collection}")
3175
3527
  return 0
3176
3528
  name = f"agent-sync — {repo_name()}"
3177
- data = ad._call("collections.create", {"name": name, "description":
3178
- "Coordination plane for agent-sync. Generated pages "
3179
- "are stamped; edit sources in git."})
3180
- print(f"✓ created collection '{name}'")
3529
+ oid = ad.create_container(name)
3530
+ print(f" created container '{name}'")
3181
3531
  print(f"\nNEXT: put this in {ENV_FILE}:")
3182
- print(f" AGENT_SYNC_OUTLINE_COLLECTION={data['id']}")
3532
+ print(f" {cls.COLLECTION_ENV}={oid}")
3183
3533
  return 0
3184
3534
 
3185
3535
 
@@ -4027,7 +4377,7 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
4027
4377
  raise Fail(f"{CONFIG_PATH} is not valid JSON: {exc}") from exc
4028
4378
  ok.append(f"config parses ({CONFIG_PATH})")
4029
4379
 
4030
- if cfg.get("backend") not in ("outline", "fs"):
4380
+ if cfg.get("backend") not in BACKENDS:
4031
4381
  problems.append(f"backend '{cfg.get('backend')}' is not a known adapter")
4032
4382
  for k in sorted(set(cfg) - CONFIG_KEYS):
4033
4383
  problems.append(f"config key '{k}' is not in the schema — it will be ignored")
@@ -4142,20 +4492,19 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
4142
4492
  ok.append(f"credentials file in force: {env} (outside this repository)")
4143
4493
  elif env is not None:
4144
4494
  ok.append(f"credentials file in force: {env}")
4145
- if cfg.get("backend") == "outline":
4495
+ cloud = CLOUD_ADAPTERS.get(cfg.get("backend") or "")
4496
+ if cloud is not None:
4146
4497
  if env is None:
4147
4498
  problems.append(f"no {ENV_FILE} found here or in any parent — the backend "
4148
4499
  "cannot be reached, and every run silently degrades")
4149
4500
  else:
4150
4501
  ok.append(f"credentials file found at {env}")
4151
- missing = [k for k in ("AGENT_SYNC_OUTLINE_URL", "AGENT_SYNC_OUTLINE_TOKEN")
4152
- if not os.environ.get(k)]
4502
+ missing = [k for k in cloud.REQUIRED_ENV if not os.environ.get(k)]
4153
4503
  if missing:
4154
4504
  problems.append(f"{', '.join(missing)} is empty — runs will degrade to `fs`")
4155
4505
  else:
4156
4506
  try:
4157
- OutlineAdapter().resolve_collection()
4158
- ok.append("knowledge base reachable and the collection resolves")
4507
+ ok.append(cloud().preflight() or f"{cloud.name}: credentials present")
4159
4508
  except Fail as exc:
4160
4509
  problems.append(f"knowledge base unreachable: {exc}")
4161
4510
 
@@ -4467,7 +4816,7 @@ def build_parser() -> argparse.ArgumentParser:
4467
4816
  sub = p.add_subparsers(dest="cmd", required=True)
4468
4817
 
4469
4818
  i = sub.add_parser("init", help="ask where to store, write config and env file")
4470
- i.add_argument("--backend", required=True, choices=["outline", "fs"])
4819
+ i.add_argument("--backend", required=True, choices=list(BACKENDS))
4471
4820
  i.add_argument("--url", help="instance URL (required for outline)")
4472
4821
  i.add_argument("--force", action="store_true")
4473
4822
  i.set_defaults(fn=cmd_init)