@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 +73 -0
- package/README.md +19 -1
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +11 -7
- package/plugins/agent-sync/skills/agent-sync/references/backend-notion.md +153 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +374 -25
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
|
[](https://github.com/ssheleg/agent-sync/actions/workflows/validate.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@ssheleg/agent-sync)
|
|
5
5
|
[](LICENSE)
|
|
6
|
+
[](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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
103
|
-
machines. **
|
|
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:
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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.
|
|
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/,
|
|
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
|
-
|
|
773
|
-
|
|
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
|
-
|
|
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 == "
|
|
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.
|
|
3009
|
-
print("\n✗
|
|
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
|
-
|
|
3170
|
-
|
|
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
|
|
3173
|
-
if ad
|
|
3174
|
-
print(f"
|
|
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
|
-
|
|
3178
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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=
|
|
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)
|