@ssheleg/agent-sync 1.16.0 → 1.18.0

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,94 @@
1
+ ## v1.18.0 — a third backend, and two people who can finally see each other
2
+
3
+ `fs` keeps the record on one machine and `outline` costs $10 a month for a team; between
4
+ them sat the case this release is for — **two people, two machines, one plane, nothing to
5
+ pay**. `notion` is that plane. The container is a page, every log is a child page of it,
6
+ and every line is one paragraph block appended by `PATCH /v1/blocks/{id}/children`, which
7
+ is a server-side append with no read-modify-write anywhere in the class.
8
+
9
+ **The capability flags are earned, and the earning found two defects.**
10
+ `test/notion_live_test.py` ran against a live workspace on 2026-08-25 and passed on the
11
+ fourth attempt: `200 appends from two processes, 200 lines on one page`, `two reads return
12
+ one order`, `tree.ensure is idempotent`, `both fresh shards enumerated, 200 lines across
13
+ them`, and the forced degradation. The three runs before it are the reason to trust the
14
+ fourth. Run two went red at 171 lines with a dead writer: the retry path had never executed
15
+ in run one, and it turned out `TimeoutError` is an `OSError` rather than a `URLError`, so a
16
+ read that timed out **after** the connection was established fell past both retry branches
17
+ into the catch-all and failed permanently. Run three went red differently — 100 lines, all
18
+ from one writer, both exiting 0 — because the two writers each called `tree.ensure` on a
19
+ shared title and the check-then-create raced into two pages of that name. That one is a
20
+ finding about the measurement: `Sync.log_id()` says *this run's OWN shard, one writer per
21
+ document, always*, so the protocol never creates it. The contention case is now handed the
22
+ page id, and the case the sharded design actually depends on took its place — two fresh
23
+ shards, both enumerated, which is precisely what Outline's search index could not do.
24
+
25
+ The suite covers both halves of the retry loop now. The success half had never existed:
26
+ every assertion drove a transport that always failed, so nothing measured whether a retry
27
+ can succeed — a loop that cannot is a loop that only fails more slowly.
28
+
29
+ It needs credentials and a network, so it stays outside `npm test` and **says SKIP loudly**
30
+ rather than passing quietly where it cannot run. `exclusiveLease` stays false and no
31
+ measurement can move it: Notion has no compare-and-swap, and the endpoint's own
32
+ `conflict_error` is a retry hint rather than an arbiter.
33
+
34
+ **One registry of backends, because three copies of a two-item list is how a backend comes
35
+ to half-exist.** `CLOUD_ADAPTERS` is the single home; `init`'s argument parser, `check`'s
36
+ known-adapter test and `bootstrap`'s dispatch all read it. `bootstrap` used to build an
37
+ Outline collection whatever the project was configured for — on an `fs` project it demanded
38
+ Outline credentials for a backend that has no container at all. `check`'s credential test
39
+ now asks the adapter (`REQUIRED_ENV`, `preflight()`) instead of naming Outline's variables
40
+ in an `if`, and the Notion preflight is a real call — `GET /v1/users/me` — because "knowledge
41
+ base reachable" printed after a function that only parsed a string is the defect class this
42
+ suite exists to refuse.
43
+
44
+ **B-34's other half.** `_allocated_ids` still read `nextFreeIdPattern` directly while
45
+ `id_pattern()` beside it accepted both keys, so a config written with the modern `pattern`
46
+ key never discarded its own *Next free ID* line: `--set-baseline` stamped one above reality,
47
+ and every id at the true top read as pre-baseline and was never asked for an as-built
48
+ record. Reproduced in `fabric` on 2026-08-25, fixed here, fixture plants it back.
49
+
50
+ **`FsAdapter`'s docstring said its files are "committed and pushed"** while `check` reports a
51
+ tracked `.agent-sync/` as a problem — a committed run id hands one checkout's identity to
52
+ every clone. The check was right; the line now says so.
53
+
54
+ **And a defect in the suite itself.** `Sync()` calls `load_env_file`, which writes the
55
+ project's variables into `os.environ` — correct for the CLI, a leak in-process. Two checks
56
+ built temporary `fs` projects and left `AGENT_SYNC_BACKEND=fs` behind, and that variable
57
+ OVERRIDES the configured backend, so the next check to depend on it silently read the
58
+ previous check's project. The new dispatch check passed alone and failed in the suite, which
59
+ is the signature of this class rather than of the code under test. Every check now runs
60
+ through `_guarded`, which restores the environment and the working directory after it.
61
+
62
+ Five checks, five planted defects: 51 → 56 fixtures.
63
+
64
+ ## v1.17.0 — the identity that had never once been established
65
+
66
+ `_session_key()` falls back to one `shared` entry per checkout when it cannot tell which
67
+ session is asking, and everything downstream hedges about it: `classify_lock` answers
68
+ `ambiguous` rather than `reapable`, so an expired lease can never be cleared by the run that
69
+ took it. The fallback was documented as the rare case.
70
+
71
+ It was the only case. Measured 2026-08-25 in a checkout that had been running the tool all
72
+ day: `.agent-sync/sessions` did not exist, and `.agent-sync/run-id` held exactly one key —
73
+ `shared`. The stamping block in `session-start.sh` required `CLAUDE_SESSION_ID` in the hook's
74
+ **environment**, and Claude Code delivers the id to a hook on **stdin as JSON**, which is how
75
+ `guard.sh` next to it has always read its own payload. So the block had never run, in any
76
+ session, since it was written. A fallback that is always taken is not a fallback.
77
+
78
+ The hook reads its payload now, with the environment variable still winning where it exists.
79
+ `test/hooks_session_test.py` runs the real hook as a process — the only way this was ever
80
+ going to be caught, because every unit around it was correct — and covers the four ways it
81
+ must behave: an id from stdin is stamped, an id from the environment still wins, a payload
82
+ carrying no id stamps nothing rather than keying every session alike, and a payload that is
83
+ not JSON leaves the hook exit 0, because a SessionStart hook that throws takes the session
84
+ with it. A fifth case walks the whole chain: stamp, then the descendant's key, then the
85
+ run-id map, then `classify_lock` returning `reapable` where it used to return `ambiguous`.
86
+
87
+ Watched failing against the previous hook: 2 of the 5 cases red, and green after.
88
+
89
+ Also: the README no longer tells a reader to run `python3 test/validate.py` and `npm test`
90
+ from a package that ships no `test/` directory. It names where they run.
91
+
1
92
  ## Unreleased — both `AS-01` halves exercised outside their fixtures
2
93
 
3
94
  The two rows sat at priority `unverified`: shipped, and confirmed by nothing but their own
package/README.md CHANGED
@@ -3,6 +3,13 @@
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
+ **[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)
9
+
10
+ Loads in **DeepSeek Harness** (`dsh`) with **no plugin to write**: it reads the
11
+ Agent Skills standard directly, scanning `~/.agents/skills` — where `npx skills
12
+ add` puts this pack — at rank 500.
6
13
 
7
14
  **Several coding agents, one repository, no collisions — and each one can see what the
8
15
  others are doing.**
@@ -444,6 +451,10 @@ and `.agent-sync/` if you want the project clean too.
444
451
 
445
452
  ## Develop and verify
446
453
 
454
+ <!-- commands-run-in: a clone -->
455
+ These run **in a clone of this repository**. The published npm package ships no
456
+ `test/` directory, so from an install they are names, not commands.
457
+
447
458
  ```bash
448
459
  python3 test/validate.py # manifests, version sync, no host/credential leaks
449
460
  python3 test/validate.py --self-test # the validator must still be able to fail
@@ -451,7 +462,7 @@ npm test # both of the above
451
462
  ```
452
463
 
453
464
  What ships: one skill (`agent-sync`), `scripts/agent_sync.py` (stdlib only), four hook
454
- scripts, the slash command, `agent-sync.schema.json`, and ten reference contracts the
465
+ scripts, the slash command, `agent-sync.schema.json`, and eleven reference contracts the
455
466
  agent loads on their own trigger rather than by default:
456
467
 
457
468
  | Reference | Read it when |
@@ -459,6 +470,7 @@ agent loads on their own trigger rather than by default:
459
470
  | [`adapter-contract.md`](plugins/agent-sync/skills/agent-sync/references/adapter-contract.md) | adding or auditing a knowledge backend |
460
471
  | [`lease-protocol.md`](plugins/agent-sync/skills/agent-sync/references/lease-protocol.md) | changing acquisition, expiry, stealing or id allocation |
461
472
  | [`backend-outline.md`](plugins/agent-sync/skills/agent-sync/references/backend-outline.md) | making any Outline API call, or debugging one |
473
+ | [`backend-notion.md`](plugins/agent-sync/skills/agent-sync/references/backend-notion.md) | making any Notion API call, or debugging one |
462
474
  | [`backend-fs.md`](plugins/agent-sync/skills/agent-sync/references/backend-fs.md) | running without a cloud backend, or explaining degraded mode |
463
475
  | [`pipeline-binding.md`](plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md) | wiring `pipeline.json`, or adding a stage hook |
464
476
  | [`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.16.0",
3
+ "version": "1.18.0",
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"
@@ -17,7 +17,7 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "test": "python3 test/validate.py && python3 test/validate.py --self-test && python3 test/claim_cell_test.py",
20
+ "test": "python3 test/validate.py && python3 test/validate.py --self-test && python3 test/claim_cell_test.py && python3 test/hooks_session_test.py",
21
21
  "prepublishOnly": "python3 test/validate.py"
22
22
  },
23
23
  "publishConfig": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.16.0",
4
+ "version": "1.18.0",
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",
@@ -6,14 +6,36 @@ S="$AGENT_SYNC_PY"
6
6
  agent_sync_configured || exit 0
7
7
 
8
8
  # Stamp who this session is, keyed by the process every command in it descends from.
9
- # A hook has CLAUDE_SESSION_ID in its environment and a plain shell command does not, so without
10
- # this a second session in the same checkout adopts the first one's identity: both acquire and
11
- # release as one run, and the lease stops separating the exact case it exists for. $PPID here is
12
- # the CLI process, which is the one ancestor every later command shares.
13
- if [ -n "${CLAUDE_SESSION_ID:-}" ]; then
9
+ # Without this a second session in the same checkout adopts the first one's identity: both
10
+ # acquire and release as one run, and the lease stops separating the exact case it exists for.
11
+ # $PPID here is the CLI process, which is the one ancestor every later command shares.
12
+ #
13
+ # The id arrives on STDIN as JSON, the way every other hook here reads its payload -- see
14
+ # guard.sh. This block used to require CLAUDE_SESSION_ID in the ENVIRONMENT and nothing else,
15
+ # so on this machine it never ran once: measured 2026-08-25, `.agent-sync/sessions` had never
16
+ # been created and the run-id map held a single `shared` key, which is the weak identity that
17
+ # makes an expired lease unattributable and `reap` refuse it forever. A fallback that is always
18
+ # taken is not a fallback.
19
+ if [ -t 0 ]; then
20
+ payload="" # no stdin (invoked by hand) -- do not block on `cat`
21
+ else
22
+ payload=$(cat 2>/dev/null || true)
23
+ fi
24
+ sid="${CLAUDE_SESSION_ID:-}"
25
+ if [ -z "$sid" ] && [ -n "$payload" ]; then
26
+ sid=$(printf '%s' "$payload" | python3 -c '
27
+ import json,sys
28
+ try:
29
+ d = json.load(sys.stdin)
30
+ except Exception:
31
+ sys.exit(0)
32
+ print(d.get("session_id") or "")
33
+ ' 2>/dev/null)
34
+ fi
35
+ if [ -n "$sid" ]; then
14
36
  d="$(git rev-parse --show-toplevel 2>/dev/null)/.agent-sync/sessions"
15
37
  if mkdir -p "$d" 2>/dev/null; then
16
- printf '%s' "$CLAUDE_SESSION_ID" > "$d/$PPID" 2>/dev/null || true
38
+ printf '%s' "$sid" > "$d/$PPID" 2>/dev/null || true
17
39
  # forget the stamps of processes that are gone, so the directory cannot grow without bound
18
40
  for f in "$d"/*; do
19
41
  b="$(basename "$f")"
@@ -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.16.0"
7
+ version: "1.18.0"
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
 
@@ -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.16.0"
36
+ VERSION = "1.18.0"
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)