@ssheleg/agent-sync 1.2.4 → 1.3.3
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 +123 -0
- package/README.md +7 -2
- package/package.json +2 -2
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/commands/agent-sync.md +5 -1
- package/plugins/agent-sync/hooks/session-start.sh +19 -0
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +24 -1
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +6 -2
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-311.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-314.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-39.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +430 -31
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,129 @@
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
This project adheres to [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 1.3.3 — 2026-07-29
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **The git lease backend could not take a lease on any machine without a git
|
|
10
|
+
identity.** Acquiring writes a lease object with `git commit-tree`, which
|
|
11
|
+
refuses when `user.email` is unset and cannot be auto-detected — CI runners,
|
|
12
|
+
containers, a freshly provisioned box. So the backend that advertises
|
|
13
|
+
*exclusive across machines* failed on precisely the machines least likely to
|
|
14
|
+
have a personal git config, with `could not create the lease object` and no
|
|
15
|
+
further detail. The lease object is plumbing, not authorship: it now carries a
|
|
16
|
+
fixed `agent-sync <agent-sync@localhost>` identity passed inline, so it never
|
|
17
|
+
depends on ambient config.
|
|
18
|
+
- The same failure now reports git's own last line instead of swallowing it. The
|
|
19
|
+
bug survived six red CI runs because the message named a possible cause and
|
|
20
|
+
showed no evidence.
|
|
21
|
+
|
|
22
|
+
## 1.3.2 — 2026-07-29
|
|
23
|
+
|
|
24
|
+
Open-source hygiene — the repo is public and ships in the `sshlg-skills` bundle,
|
|
25
|
+
so the files a first-time contributor looks for now exist.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- `CODE_OF_CONDUCT.md`, issue forms and a pull-request template. The forms ask
|
|
29
|
+
the question that actually matters for this project: **how many agents were
|
|
30
|
+
running, against what checkout** — a coordination bug reported without the
|
|
31
|
+
concurrency shape is not reproducible.
|
|
32
|
+
- The PR checklist requires a negative self-test with any new validator guard —
|
|
33
|
+
plant the defect, watch the check fail, then trust the green.
|
|
34
|
+
- README points at the code of conduct and at the family bundle.
|
|
35
|
+
|
|
36
|
+
## 1.3.1 — 2026-07-29
|
|
37
|
+
|
|
38
|
+
### The git lease was invisible to everything that reads a lease — fixed
|
|
39
|
+
|
|
40
|
+
Found in production, blocking real work three times in one session. In `git` lease mode `acquire`
|
|
41
|
+
won the lease by pushing `refs/agent-sync/leases/<key>` and stopped there, while `held()` — the one
|
|
42
|
+
function behind `whoami`, `status` and the **PreToolUse guard** — read `.agent-sync/leases/*.lock`,
|
|
43
|
+
which nothing in that path ever wrote. The result was the exact inversion of the tool's purpose:
|
|
44
|
+
`acquire` printed *won*, `whoami` printed *holds: nothing*, and the guard **denied the run that held
|
|
45
|
+
the lease**. Every guarded register was unwritable under the mode this tool recommends, and the only
|
|
46
|
+
way past it was to bypass the guard — which is the behaviour the guard exists to prevent.
|
|
47
|
+
|
|
48
|
+
The git ref remains the authority; it is what makes exclusion hold across machines. What was missing
|
|
49
|
+
is that the winner now leaves a local note, so the local question — *does this run hold that key?* —
|
|
50
|
+
is answered locally instead of putting a network round-trip in front of every Edit. `release`
|
|
51
|
+
already removed that note, which is why only one half of the loop was ever written.
|
|
52
|
+
|
|
53
|
+
**Why it shipped:** the lease-visibility assertion existed only for the local mode. `test/validate.py`
|
|
54
|
+
now runs acquire → `whoami` → `guard` → release against **both** modes; against 1.3.0 it fails with
|
|
55
|
+
the two symptoms above, which is the point of adding it.
|
|
56
|
+
|
|
57
|
+
## 1.3.1 — 2026-07-29
|
|
58
|
+
|
|
59
|
+
### Ignoring the state directory does nothing once git is tracking it
|
|
60
|
+
|
|
61
|
+
Found in the project this plugin was built for: `.agent-sync/` was gitignored **and committed**,
|
|
62
|
+
because the files went in before the rule existed. Consequences, all of them silent:
|
|
63
|
+
|
|
64
|
+
- every tool call rewrites `last-renew`, so all three repositories were permanently dirty and no
|
|
65
|
+
run could ever report itself finished
|
|
66
|
+
- `run-id` is the checkout's **agent identity**. Committed, it reaches every clone — two machines
|
|
67
|
+
would then coordinate as one run, which is the failure 1.3.0 fixed at the other end
|
|
68
|
+
|
|
69
|
+
`init` now untracks the directory when it finds it tracked, and `check` reports it as a problem
|
|
70
|
+
rather than passing a project whose state is versioned. Probed: a repository with a committed
|
|
71
|
+
`.agent-sync/run-id` fails `check` with the exact removal command, and passes once it is untracked.
|
|
72
|
+
|
|
73
|
+
## 1.3.0 — 2026-07-29
|
|
74
|
+
|
|
75
|
+
### Two agents in one checkout were one identity — fixed
|
|
76
|
+
|
|
77
|
+
Found in production, in the case this plugin exists for: **two Claude sessions working the same
|
|
78
|
+
checkout shared a single run id**, so the lease could not separate them. A hook runs with
|
|
79
|
+
`CLAUDE_SESSION_ID` in its environment and a plain shell command does not, and the marker file held
|
|
80
|
+
one id per checkout — so the second session adopted whatever the first had stamped. Both acquired
|
|
81
|
+
as one run, both were guarded as one run, and `release` would take a lease the caller never
|
|
82
|
+
acquired. The failure is silent: `whoami` reports a lease, and it is somebody else's.
|
|
83
|
+
|
|
84
|
+
- the marker is now a **map** keyed by session, and migrates the old single-value file into it
|
|
85
|
+
- a plain shell has no session id, so the `SessionStart` hook stamps
|
|
86
|
+
`.agent-sync/sessions/<CLI pid>` with the session it *does* know, and later commands find
|
|
87
|
+
themselves by walking their own ancestry. Exact, and no command-line parsing: the throwaway
|
|
88
|
+
shell every tool call runs in carries claude paths in its argv and defeated every heuristic
|
|
89
|
+
aimed at the CLI binary
|
|
90
|
+
- stale stamps are removed when their process is gone, so the directory cannot grow
|
|
91
|
+
- where identity still cannot be established, the run says so rather than presenting a shared
|
|
92
|
+
entry as separation
|
|
93
|
+
|
|
94
|
+
### `scaffold --full` — the architecture that keeps documentation linked, not merely present
|
|
95
|
+
|
|
96
|
+
`scaffold` seeded a decision register and an agent protocol. That is enough to be coordinated and
|
|
97
|
+
not enough to stay coherent: the things that rot are the links between documents, and nothing was
|
|
98
|
+
seeding the pieces that hold them — a question register that resolves into decisions, an index
|
|
99
|
+
nobody has to scan the register to use, one place for facts about two repositories, one definition
|
|
100
|
+
per entity with a checkable address, **and a gate**, because each of those decays silently.
|
|
101
|
+
|
|
102
|
+
`--full` adds `OPEN_QUESTIONS.md`, `INDEX.md`, `DEPENDENCIES.md`, `DATA_MODEL.md` (with the entity
|
|
103
|
+
register and the one-definition rule) and `scripts/check-docs.sh`, which fails on: an id cited and
|
|
104
|
+
never defined, a next-free-ID line that is not next, a relative link to a file that does not exist,
|
|
105
|
+
a `#anchor` that does not exist in the file it points at, and a decision with no index row. All
|
|
106
|
+
five probed against planted defects.
|
|
107
|
+
|
|
108
|
+
**A fresh scaffold passes its own gate.** The first version did not — it counted the template block
|
|
109
|
+
and the allocation line as real ids — and a project that starts red teaches everyone that the gate
|
|
110
|
+
is noise.
|
|
111
|
+
|
|
112
|
+
### `finish` — the gate expressions this plugin declares, executed
|
|
113
|
+
|
|
114
|
+
`references/pipeline-binding.md` has always listed *submodule pointers current* and *every lease
|
|
115
|
+
released* as gate expressions "verified by the coordinator, not by prose". Nothing ran them:
|
|
116
|
+
`check` validates the **setup** — config, registers, credentials, snapshot — and never looks at the
|
|
117
|
+
state of the repositories.
|
|
118
|
+
|
|
119
|
+
`finish` answers the other question, *is the work finished*:
|
|
120
|
+
|
|
121
|
+
- every submodule's recorded gitlink equals its HEAD. This is the failure it exists for and it is
|
|
122
|
+
invisible from either side alone: the submodule is pushed, its CI is green, its roadmap says
|
|
123
|
+
done, and a clone of the parent has the commit before the work
|
|
124
|
+
- every repository — parent included — is clean and pushed, with a detached submodule accepted
|
|
125
|
+
only when its commit exists on some remote branch
|
|
126
|
+
- no lease left held, because a run that ends holding one blocks the next agent for the whole TTL
|
|
127
|
+
- `--gates` also runs the project's own declared gate commands
|
|
128
|
+
|
|
6
129
|
## 1.2.4 — 2026-07-29
|
|
7
130
|
|
|
8
131
|
### Fixed — the tool misreported its own version, and disagreed with itself about the lease
|
package/README.md
CHANGED
|
@@ -389,8 +389,13 @@ agent loads on their own trigger rather than by default:
|
|
|
389
389
|
| [`two-sources.md`](plugins/agent-sync/skills/agent-sync/references/two-sources.md) | before the first reconcile, or when deciding where a document belongs |
|
|
390
390
|
| [`roadmap.md`](plugins/agent-sync/skills/agent-sync/references/roadmap.md) | configuring `claimTags`, taking or closing a task, or re-planning a board |
|
|
391
391
|
|
|
392
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md).
|
|
393
|
-
|
|
392
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). Everyone
|
|
393
|
+
taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
394
|
+
Security reports: [SECURITY.md](SECURITY.md).
|
|
395
|
+
|
|
396
|
+
`agent-sync` also ships in the [sshlg-skills](https://github.com/ssheleg/sshlg-skills)
|
|
397
|
+
bundle, which installs the whole family for Claude Code, Cursor, Codex and 70+
|
|
398
|
+
other agents with one command.
|
|
394
399
|
|
|
395
400
|
## License
|
|
396
401
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Let concurrent coding agents share one project without colliding
|
|
3
|
+
"version": "1.3.3",
|
|
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"
|
|
7
7
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"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.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "appvillis-com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Coordinate concurrent agents — initialise the shared knowledge store, check status, claim a task, reserve an id, or regenerate the board.
|
|
3
|
-
argument-hint: "[init|status|claim <KEY>|release <KEY>|reserve <REG>|board]"
|
|
3
|
+
argument-hint: "[init|status|claim <KEY>|release <KEY>|reserve <REG>|board|finish]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Invoke the `agent-sync` skill.
|
|
@@ -14,3 +14,7 @@ instance URL) or local files. Never guess that answer.
|
|
|
14
14
|
|
|
15
15
|
If the project is already initialised, report status and name exactly one next
|
|
16
16
|
action.
|
|
17
|
+
|
|
18
|
+
With `finish`, run the end-of-work check instead: every repository clean, pushed and pointed at,
|
|
19
|
+
and no lease left held. In a project of git submodules that is the one failure nobody sees — the
|
|
20
|
+
submodule is pushed and the parent still points at the commit before the work.
|
|
@@ -4,5 +4,24 @@ set -uo pipefail
|
|
|
4
4
|
. "${CLAUDE_PLUGIN_ROOT}/hooks/_lib.sh"
|
|
5
5
|
S="$AGENT_SYNC_PY"
|
|
6
6
|
agent_sync_configured || exit 0
|
|
7
|
+
|
|
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
|
|
14
|
+
d="$(git rev-parse --show-toplevel 2>/dev/null)/.agent-sync/sessions"
|
|
15
|
+
if mkdir -p "$d" 2>/dev/null; then
|
|
16
|
+
printf '%s' "$CLAUDE_SESSION_ID" > "$d/$PPID" 2>/dev/null || true
|
|
17
|
+
# forget the stamps of processes that are gone, so the directory cannot grow without bound
|
|
18
|
+
for f in "$d"/*; do
|
|
19
|
+
b="$(basename "$f")"
|
|
20
|
+
case "$b" in *[!0-9]*) continue ;; esac
|
|
21
|
+
kill -0 "$b" 2>/dev/null || rm -f "$f"
|
|
22
|
+
done
|
|
23
|
+
fi
|
|
24
|
+
fi
|
|
25
|
+
|
|
7
26
|
run_limited 10 python3 "$S" status 2>&1 || true
|
|
8
27
|
exit 0
|
|
@@ -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.3.3"
|
|
8
8
|
author: appvillis-com
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -191,11 +191,34 @@ npx sshlg-skills install
|
|
|
191
191
|
| `adopt` | Inspect an existing project and **propose** a config — writes nothing |
|
|
192
192
|
| `scaffold` | Create the missing documentation architecture. Never overwrites |
|
|
193
193
|
| `check` | Validate the whole setup end to end. Non-zero when it is not healthy |
|
|
194
|
+
| `scaffold [--full]` | Create only what is missing, never a line over anything that exists. `--full` also seeds the question register, the index, the dependency board, the data model with its entity register, and the docs gate |
|
|
195
|
+
| `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at, no lease left held. `check` answers whether the project is wired correctly; this answers whether you are done |
|
|
194
196
|
|
|
195
197
|
`$SKILL_DIR` is this skill's own directory. Every command reads
|
|
196
198
|
`.claude/agent-sync.json` from the project root and needs no arguments beyond those
|
|
197
199
|
listed.
|
|
198
200
|
|
|
201
|
+
## One identity per session, and how it is decided
|
|
202
|
+
|
|
203
|
+
A lease is only a lease if two agents get two identities. Ordering matters here and both ends have
|
|
204
|
+
bitten: deriving the id from `CLAUDE_SESSION_ID` alone gave **one session two identities** — it
|
|
205
|
+
acquired as one and was denied by its own guard as the other — and keeping one id per checkout gave
|
|
206
|
+
**two sessions one identity**, which is worse. The second is silent: both sessions acquire, both are
|
|
207
|
+
guarded, and `release` takes a lease the caller never had.
|
|
208
|
+
|
|
209
|
+
The order is: `AGENT_SYNC_RUN_ID` · `CLAUDE_SESSION_ID` · **the session that started this shell** ·
|
|
210
|
+
shared.
|
|
211
|
+
|
|
212
|
+
The third is the one that matters, because a plain shell command has no session id and a hook does.
|
|
213
|
+
So `SessionStart` stamps `.agent-sync/sessions/<the CLI's pid>` with the session it knows, and a
|
|
214
|
+
later command finds itself by walking its own process ancestry to a pid that appears there. It is
|
|
215
|
+
exact, and it deliberately does **not** parse command lines: the throwaway shell every tool call
|
|
216
|
+
runs in carries claude paths in its own argv, so every heuristic aimed at the CLI binary matched it
|
|
217
|
+
instead. Stamps are removed when their process is gone.
|
|
218
|
+
|
|
219
|
+
When none of the four can be established the run says so — *"this identity is shared with any other
|
|
220
|
+
session in this checkout"* — rather than presenting a shared entry as separation.
|
|
221
|
+
|
|
199
222
|
## Claiming — the shape that matters
|
|
200
223
|
|
|
201
224
|
```
|
|
@@ -64,13 +64,17 @@ user-facing work) and `make-skill`, and it prunes the duplicate plain-copy shado
|
|
|
64
64
|
|
|
65
65
|
## Gate expressions
|
|
66
66
|
|
|
67
|
-
Each `check` above is verified by the coordinator, not by prose
|
|
67
|
+
Each `check` above is verified by the coordinator, not by prose — and since 1.3.0 the repository
|
|
68
|
+
half of that verification is a command rather than a promise: **`agent_sync.py finish`** runs the
|
|
69
|
+
pointer, cleanliness, pushed-ness and lease checks, and `finish --gates` adds the project's own
|
|
70
|
+
declared gates. Until then this table described work nothing performed.
|
|
68
71
|
|
|
69
72
|
| Check | How it is decided |
|
|
70
73
|
|---|---|
|
|
71
74
|
| lease held | replay the log; holder == this run |
|
|
72
75
|
| every id reserved | every `DEC-`/`OQ-`/`DEP-`-shaped token new in the diff has a `reserve` line in this run |
|
|
73
76
|
| no two parallel tasks write one file | intersect the file lists journaled at stage 4 |
|
|
74
|
-
| submodule pointers current | `git submodule status` reports no `+` prefix |
|
|
77
|
+
| submodule pointers current | `git submodule status` reports no `+` prefix — `finish` |
|
|
78
|
+
| every repository pushed | no commits ahead of upstream anywhere, parent included — `finish` |
|
|
75
79
|
| board regenerated, no drift | each mirror stamp equals `git rev-parse HEAD` for its source |
|
|
76
80
|
| every lease released | replay the log; this run holds nothing |
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -32,7 +32,7 @@ from datetime import datetime, timezone
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
from typing import Any
|
|
34
34
|
|
|
35
|
-
VERSION = "1.
|
|
35
|
+
VERSION = "1.3.3"
|
|
36
36
|
|
|
37
37
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
38
38
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -208,46 +208,103 @@ def load_config(root: Path) -> dict[str, Any]:
|
|
|
208
208
|
raise Fail(f".claude/agent-sync.json is not valid JSON: {exc}") from exc
|
|
209
209
|
|
|
210
210
|
|
|
211
|
-
def
|
|
212
|
-
"""
|
|
211
|
+
def _session_key() -> tuple[str, str]:
|
|
212
|
+
"""Who is asking — and how confidently.
|
|
213
|
+
|
|
214
|
+
Returns `(key, how)`. `how` is reported to the user, because the three answers are not
|
|
215
|
+
equally strong and an identity nobody can question is how two agents end up as one.
|
|
213
216
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
The hard case is real and was found in production: **two Claude sessions in the same
|
|
218
|
+
checkout.** A hook runs with `CLAUDE_SESSION_ID` in its environment; a plain shell command
|
|
219
|
+
does not. With a single marker file per checkout, the second session adopts whatever the
|
|
220
|
+
first stamped — so both acquire, release and are guarded as one run. The lease then fails to
|
|
221
|
+
separate exactly the case it exists for, silently, and one agent can release the other's
|
|
222
|
+
lease mid-work.
|
|
218
223
|
|
|
219
|
-
The
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
command in a fresh checkout would fork the identity all over again.
|
|
224
|
+
The process tree is what a plain shell still has. Every Claude session runs under its own
|
|
225
|
+
`claude` process, so the nearest such ancestor identifies the session even when the
|
|
226
|
+
environment does not. A pid can be recycled, so it is paired with that process's start time.
|
|
223
227
|
"""
|
|
224
228
|
override = os.environ.get("AGENT_SYNC_RUN_ID")
|
|
225
229
|
if override:
|
|
226
|
-
return "
|
|
230
|
+
return "env:" + override, "AGENT_SYNC_RUN_ID"
|
|
227
231
|
|
|
228
232
|
session = os.environ.get("CLAUDE_SESSION_ID") or ""
|
|
229
|
-
|
|
233
|
+
if session:
|
|
234
|
+
return "session:" + session, "CLAUDE_SESSION_ID"
|
|
235
|
+
|
|
236
|
+
# No session id in this environment — a plain shell command has none. What it does have is
|
|
237
|
+
# an ancestor process that the SessionStart hook ran under, and that hook DID have the id.
|
|
238
|
+
# So the hook stamps `<state>/sessions/<its own parent pid>` with the session, and this walk
|
|
239
|
+
# looks for an ancestor that appears there. That is exact: no command-line parsing, which was
|
|
240
|
+
# tried and failed — the throwaway shell this very command runs in carries claude paths in
|
|
241
|
+
# its argv and matched every heuristic aimed at the CLI.
|
|
242
|
+
try:
|
|
243
|
+
root = project_root()
|
|
244
|
+
sessions = root / STATE_DIR / "sessions"
|
|
245
|
+
pid = os.getpid()
|
|
246
|
+
for _ in range(10):
|
|
247
|
+
marker = sessions / str(pid)
|
|
248
|
+
if marker.exists():
|
|
249
|
+
return "session:" + marker.read_text().strip(), "the session that started this shell"
|
|
250
|
+
out = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)],
|
|
251
|
+
capture_output=True, text=True, timeout=5)
|
|
252
|
+
ppid = out.stdout.strip()
|
|
253
|
+
if not ppid or ppid in ("0", "1", str(pid)):
|
|
254
|
+
break
|
|
255
|
+
pid = int(ppid)
|
|
256
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
return "", "nothing — this identity is shared with any other session in this checkout"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_id(root: Path) -> str:
|
|
263
|
+
"""One identity per session, whichever way the tool is invoked.
|
|
264
|
+
|
|
265
|
+
Load-bearing in both directions. Deriving the id from `CLAUDE_SESSION_ID` alone gave one
|
|
266
|
+
session two identities — the agent acquired a lease as one and was denied by its own guard as
|
|
267
|
+
the other. Keeping a single id per checkout gave two sessions one identity, which is worse:
|
|
268
|
+
the guard let each of them write the other's guarded files and `release` took a lease its run
|
|
269
|
+
never acquired.
|
|
230
270
|
|
|
231
|
-
|
|
271
|
+
So the marker is a **map**, keyed by whatever `_session_key()` could establish. A key that
|
|
272
|
+
cannot be established falls back to the shared entry — the old behaviour, kept because it is
|
|
273
|
+
better than minting a fresh identity on every shell command, and reported as weak rather than
|
|
274
|
+
presented as separation.
|
|
275
|
+
"""
|
|
276
|
+
key, _how = _session_key()
|
|
277
|
+
if key.startswith("env:"):
|
|
278
|
+
return "r-" + re.sub(r"[^a-z0-9]", "", key[4:].lower())[:12]
|
|
279
|
+
|
|
280
|
+
marker = root / STATE_DIR / "run-id"
|
|
281
|
+
data: dict[str, Any] = {}
|
|
232
282
|
if marker.exists():
|
|
233
283
|
raw = marker.read_text().strip()
|
|
234
284
|
try:
|
|
235
|
-
|
|
285
|
+
parsed = json.loads(raw)
|
|
236
286
|
except json.JSONDecodeError:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
287
|
+
parsed = {"run": raw, "session": ""}
|
|
288
|
+
if isinstance(parsed, dict) and "runs" in parsed:
|
|
289
|
+
data = parsed
|
|
290
|
+
elif isinstance(parsed, dict) and parsed.get("run"):
|
|
291
|
+
# migrate the single-value marker: it belonged to whichever session stamped it
|
|
292
|
+
legacy_key = ("session:" + parsed["session"]) if parsed.get("session") else "shared"
|
|
293
|
+
data = {"runs": {legacy_key: {"run": parsed["run"], "seen": now_iso()}}}
|
|
294
|
+
data.setdefault("runs", {})
|
|
295
|
+
|
|
296
|
+
entry = data["runs"].get(key or "shared")
|
|
297
|
+
if entry and entry.get("run"):
|
|
298
|
+
entry["seen"] = now_iso()
|
|
299
|
+
rid = entry["run"]
|
|
300
|
+
else:
|
|
301
|
+
session = os.environ.get("CLAUDE_SESSION_ID") or ""
|
|
302
|
+
rid = ("r-" + re.sub(r"[^a-z0-9]", "", session.lower())[:12]) if session else \
|
|
303
|
+
"r-%06x%s" % (random.getrandbits(24), format(int(time.time()) & 0xFFF, "03x"))
|
|
304
|
+
data["runs"][key or "shared"] = {"run": rid, "seen": now_iso()}
|
|
305
|
+
|
|
249
306
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
-
marker.write_text(json.dumps(
|
|
307
|
+
marker.write_text(json.dumps(data, indent=1))
|
|
251
308
|
return rid
|
|
252
309
|
|
|
253
310
|
|
|
@@ -718,6 +775,27 @@ class Sync:
|
|
|
718
775
|
except (json.JSONDecodeError, ValueError):
|
|
719
776
|
return sha, {}
|
|
720
777
|
|
|
778
|
+
def _note_local(self, key: str, payload: str) -> None:
|
|
779
|
+
"""Record locally that THIS run won THIS key here.
|
|
780
|
+
|
|
781
|
+
The git ref is the authority and stays so — it is what makes the lease exclusive across
|
|
782
|
+
machines. But `held()`, `whoami`, `status` and the PreToolUse guard all ask a *local*
|
|
783
|
+
question: does this run hold that key? Answering it by reading the remote would put a
|
|
784
|
+
network round-trip in front of every single Edit. So the winner leaves a note, and
|
|
785
|
+
`release()` — which already removes it — closes the loop.
|
|
786
|
+
|
|
787
|
+
Without this the guard denied the run that held the lease: `acquire` wrote a ref, `held()`
|
|
788
|
+
read a directory nothing had written, and every guarded file became unwritable in git mode.
|
|
789
|
+
"""
|
|
790
|
+
lock = self._local_lock(key)
|
|
791
|
+
try:
|
|
792
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
|
|
793
|
+
with os.fdopen(fd, "w") as fh:
|
|
794
|
+
fh.write(payload)
|
|
795
|
+
except OSError as exc:
|
|
796
|
+
# Never fatal: the lease is already won on the remote, which is the part that matters.
|
|
797
|
+
print(f"note: lease won but not noted locally ({exc})", file=sys.stderr)
|
|
798
|
+
|
|
721
799
|
def _git_acquire(self, key: str) -> tuple[bool, str | None]:
|
|
722
800
|
"""Push a ref that must not already exist. The remote's non-fast-forward rule
|
|
723
801
|
IS the compare-and-swap — verified against a hosted remote, not assumed."""
|
|
@@ -725,6 +803,7 @@ class Sync:
|
|
|
725
803
|
held_sha, held = self._git_read_lease(key)
|
|
726
804
|
if held:
|
|
727
805
|
if held.get("run") == self.rid:
|
|
806
|
+
self._note_local(key, json.dumps(held))
|
|
728
807
|
self._touch_renew()
|
|
729
808
|
return True, self.rid
|
|
730
809
|
alive = time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
|
|
@@ -734,10 +813,20 @@ class Sync:
|
|
|
734
813
|
payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
|
|
735
814
|
"repo": repo_name(), "host": os.uname().nodename})
|
|
736
815
|
empty_tree = git("hash-object", "-t", "tree", "/dev/null")
|
|
737
|
-
|
|
738
|
-
|
|
816
|
+
# A lease object is plumbing, not authorship, so it must not depend on the
|
|
817
|
+
# machine having a git identity. Without these `-c` flags `commit-tree`
|
|
818
|
+
# refuses wherever user.email is unset and cannot be auto-detected — CI
|
|
819
|
+
# runners, containers, a freshly provisioned box — and the lease backend
|
|
820
|
+
# silently becomes unusable on exactly the machines that need it most.
|
|
821
|
+
made = subprocess.run(
|
|
822
|
+
["git", "-c", "user.name=agent-sync", "-c", "user.email=agent-sync@localhost",
|
|
823
|
+
"commit-tree", empty_tree],
|
|
824
|
+
input=payload, capture_output=True, text=True)
|
|
825
|
+
commit = made.stdout.strip()
|
|
739
826
|
if not commit:
|
|
740
|
-
|
|
827
|
+
detail = (made.stderr or "").strip().splitlines()
|
|
828
|
+
why = detail[-1] if detail else "no output from git commit-tree"
|
|
829
|
+
raise Fail(f"could not create the lease object — is this a git repository? ({why})")
|
|
741
830
|
|
|
742
831
|
args = ["git", "push", remote, f"{commit}:{ref}"]
|
|
743
832
|
if held_sha: # stealing an expired lease, and only that
|
|
@@ -747,6 +836,7 @@ class Sync:
|
|
|
747
836
|
# Rejected: somebody won between our read and our push. Ask who.
|
|
748
837
|
_s, now_held = self._git_read_lease(key)
|
|
749
838
|
return False, now_held.get("run") or "another run"
|
|
839
|
+
self._note_local(key, payload)
|
|
750
840
|
self._touch_renew()
|
|
751
841
|
return True, self.rid
|
|
752
842
|
|
|
@@ -1698,6 +1788,7 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
1698
1788
|
|
|
1699
1789
|
ensure_gitignored(root, str(ENV_FILE))
|
|
1700
1790
|
ensure_gitignored(root, f"{STATE_DIR}/")
|
|
1791
|
+
ensure_untracked(root, f"{STATE_DIR}/")
|
|
1701
1792
|
|
|
1702
1793
|
print()
|
|
1703
1794
|
if backend == "outline":
|
|
@@ -1753,6 +1844,25 @@ def ensure_gitignored(root: Path, entry: str) -> None:
|
|
|
1753
1844
|
print(f"✓ added {entry} to .gitignore")
|
|
1754
1845
|
|
|
1755
1846
|
|
|
1847
|
+
def ensure_untracked(root: Path, entry: str) -> None:
|
|
1848
|
+
"""Ignoring a path does nothing once git is already tracking it.
|
|
1849
|
+
|
|
1850
|
+
Found in a real project: the state directory had been committed before the ignore rule
|
|
1851
|
+
existed, so `git status` reported it modified after **every** tool call — the repository was
|
|
1852
|
+
permanently dirty and no run could report itself finished. `run-id` is the worse half: it is
|
|
1853
|
+
this checkout's agent identity, and committed it reaches every clone, so two machines
|
|
1854
|
+
coordinate as one run.
|
|
1855
|
+
"""
|
|
1856
|
+
tracked = git("ls-files", "--", entry.rstrip("/"), cwd=root)
|
|
1857
|
+
if not tracked:
|
|
1858
|
+
return
|
|
1859
|
+
if git("rm", "-r", "--cached", "-q", "--", entry.rstrip("/"), cwd=root) is None:
|
|
1860
|
+
return
|
|
1861
|
+
n = len(tracked.split("\n"))
|
|
1862
|
+
print(f"✓ untracked {n} committed file(s) under {entry} — commit that removal; "
|
|
1863
|
+
"ignoring a tracked path has no effect")
|
|
1864
|
+
|
|
1865
|
+
|
|
1756
1866
|
# --------------------------------------------------------------------------- status
|
|
1757
1867
|
|
|
1758
1868
|
def cmd_status(_args: argparse.Namespace) -> int:
|
|
@@ -2075,6 +2185,149 @@ def cmd_adopt(_args: argparse.Namespace) -> int:
|
|
|
2075
2185
|
return 0
|
|
2076
2186
|
|
|
2077
2187
|
|
|
2188
|
+
OPEN_QUESTIONS_SEED = """# Open questions
|
|
2189
|
+
|
|
2190
|
+
**One job: what is not decided yet, and what settling it would unblock.**
|
|
2191
|
+
|
|
2192
|
+
**Next free ID:** `OQ-0001`
|
|
2193
|
+
|
|
2194
|
+
A question here is answered by a decision, never by a conversation: when it is settled, its row
|
|
2195
|
+
becomes `Resolved→DEC-####` and the reasoning goes in that decision. A question with no owner and
|
|
2196
|
+
no consequence is not an open question — it is a note, and it belongs somewhere else.
|
|
2197
|
+
|
|
2198
|
+
| ID | Question | Area | Status | Affects |
|
|
2199
|
+
|---|---|---|---|---|
|
|
2200
|
+
"""
|
|
2201
|
+
|
|
2202
|
+
INDEX_SEED = """# Index — one row per decision
|
|
2203
|
+
|
|
2204
|
+
**One job: find the decision without reading the register.** Generated by hand and gated: a
|
|
2205
|
+
decision with no row here fails the docs gate, because a register nobody can scan is a register
|
|
2206
|
+
nobody reads.
|
|
2207
|
+
|
|
2208
|
+
**It quotes no counts and no rule.** A restated rule is a second source with a decay rate; this
|
|
2209
|
+
file holds titles and status only.
|
|
2210
|
+
|
|
2211
|
+
| ID | Title | Status |
|
|
2212
|
+
|---|---|---|
|
|
2213
|
+
"""
|
|
2214
|
+
|
|
2215
|
+
DEPENDENCIES_SEED = """# Dependencies — the only place a fact about two repositories lives
|
|
2216
|
+
|
|
2217
|
+
**One job: name what one repository needs from another, who produces it, and who is waiting.**
|
|
2218
|
+
|
|
2219
|
+
A row carries **both sides**. A dependency with no producer task is a dependency nobody is going to
|
|
2220
|
+
build, and the block it causes is invisible from either side alone — the consumer says *blocked on
|
|
2221
|
+
DEP-003*, and DEP-003 names nobody.
|
|
2222
|
+
|
|
2223
|
+
**No status rollup.** Each row names the producer's task; the current answer is read at its source,
|
|
2224
|
+
never copied here where it drifts.
|
|
2225
|
+
|
|
2226
|
+
| ID | What is needed | Producer | Consumer | State | Notes |
|
|
2227
|
+
|---|---|---|---|---|---|
|
|
2228
|
+
"""
|
|
2229
|
+
|
|
2230
|
+
DATA_MODEL_SEED = """# Data model — one definition per thing
|
|
2231
|
+
|
|
2232
|
+
**One job: every entity defined once, with an address, so nothing is described twice and differently.**
|
|
2233
|
+
|
|
2234
|
+
Two layers, and the distinction is not cosmetic:
|
|
2235
|
+
|
|
2236
|
+
- **Conceptual entity** — what the thing *is* in the product, its identity, its relationships, and
|
|
2237
|
+
the rules that travel with it. Here.
|
|
2238
|
+
- **Physical table** — columns, types, nullability, indexes. In the owning service's own schema
|
|
2239
|
+
document.
|
|
2240
|
+
|
|
2241
|
+
A physical table **must name the conceptual entity it implements**; one that names none is a
|
|
2242
|
+
finding, because that is where two services drift without either being wrong. The reverse is a
|
|
2243
|
+
finding too: an entity with no table anywhere is a thing everyone agreed on and nobody built.
|
|
2244
|
+
|
|
2245
|
+
Every entity heading carries an explicit anchor — `## <a id="thing"></a>Thing` — so a mention
|
|
2246
|
+
elsewhere links to the definition rather than to the top of this file. An explicit id is checkable
|
|
2247
|
+
at both ends; an auto-generated slug changes the moment somebody rewords a heading, and every
|
|
2248
|
+
inbound link breaks silently.
|
|
2249
|
+
|
|
2250
|
+
## <a id="entity_register"></a>Entity register
|
|
2251
|
+
|
|
2252
|
+
| Entity | Address | Physical table in | Introduced by |
|
|
2253
|
+
|---|---|---|---|
|
|
2254
|
+
"""
|
|
2255
|
+
|
|
2256
|
+
CHECK_DOCS_SEED = r'''#!/usr/bin/env bash
|
|
2257
|
+
# The documentation gate. Seeded by agent-sync; extend it, do not replace it.
|
|
2258
|
+
#
|
|
2259
|
+
# It exists because linked documentation rots quietly: a decision cites a document that never
|
|
2260
|
+
# mentions it, a link points at a file that moved, an id is minted twice, an index row is missing.
|
|
2261
|
+
# None of those break anything visibly, and all of them cost the next reader an hour.
|
|
2262
|
+
set -uo pipefail
|
|
2263
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$ROOT" || exit 1
|
|
2264
|
+
fail=0; err() { printf 'FAIL: %s\n' "$1"; fail=1; }
|
|
2265
|
+
DOCS="${DOCS_DIR:-docs}"; [ -d "$DOCS" ] || DOCS="."
|
|
2266
|
+
md=$(find "$DOCS" -name '*.md' 2>/dev/null; ls ./*.md 2>/dev/null)
|
|
2267
|
+
|
|
2268
|
+
# 1. every id that is cited is defined somewhere
|
|
2269
|
+
for reg in DEC OQ DEP; do
|
|
2270
|
+
file=$(grep -rl "Next free ID:\*\* \`$reg-" $md 2>/dev/null | head -1)
|
|
2271
|
+
[ -z "$file" ] && continue
|
|
2272
|
+
# A definition is a heading or a table row in the register itself. The template block and the
|
|
2273
|
+
# allocation line are neither, and a gate that counts them fails a project on the day it is
|
|
2274
|
+
# created — which teaches everyone that the gate is noise.
|
|
2275
|
+
body=$(sed -n '/^```/,/^```/!p' "$file" | grep -v "Next free ID")
|
|
2276
|
+
defined=$(echo "$body" | grep -ohE "^#+ $reg-[0-9]{3,4}|^\| \[?$reg-[0-9]{3,4}" | grep -oE "$reg-[0-9]{3,4}" | sort -u)
|
|
2277
|
+
cited=$(cat $md | sed -n '/^```/,/^```/!p' | grep -v "Next free ID" | grep -ohE "\b$reg-[0-9]{3,4}\b" | sort -u)
|
|
2278
|
+
missing=$(comm -13 <(echo "$defined") <(echo "$cited"))
|
|
2279
|
+
[ -n "$missing" ] && err "$reg cited but never defined: $(echo $missing | tr '\n' ' ')"
|
|
2280
|
+
# 2. the next-free-ID line is the next one, or two agents mint the same number
|
|
2281
|
+
next=$(grep -oE "Next free ID:\*\* \`$reg-[0-9]{3,4}" "$file" | grep -oE '[0-9]{3,4}$')
|
|
2282
|
+
max=$(echo "$defined" | grep -oE '[0-9]{3,4}$' | sort -n | tail -1)
|
|
2283
|
+
if [ -n "$next" ] && [ -n "$max" ] && [ "$((10#$next))" -le "$((10#$max))" ]; then
|
|
2284
|
+
err "$reg next free id is $next but $reg-$max exists"
|
|
2285
|
+
fi
|
|
2286
|
+
done
|
|
2287
|
+
|
|
2288
|
+
# 3. every relative link resolves
|
|
2289
|
+
while IFS= read -r hit; do
|
|
2290
|
+
src="${hit%%:*}"; link="${hit#*:}"
|
|
2291
|
+
tgt="$(cd "$(dirname "$src")" && cd "$(dirname "$link")" 2>/dev/null && pwd)/$(basename "$link")"
|
|
2292
|
+
[ -e "$tgt" ] || err "$src → $link does not exist"
|
|
2293
|
+
done < <(grep -rhoE '\]\(\.{1,2}/[A-Za-z0-9_./-]+\.md' $md 2>/dev/null | sed 's/](//' | sort -u | while read -r l; do grep -rl -- "]($l" $md | head -1 | sed "s|$|:$l|"; done)
|
|
2294
|
+
|
|
2295
|
+
# 4. every #anchor a link points at exists in the file it points at
|
|
2296
|
+
python3 - "$ROOT" <<'PYEOF' || fail=1
|
|
2297
|
+
import os, re, sys
|
|
2298
|
+
root = sys.argv[1]; bad = 0; cache = {}
|
|
2299
|
+
def ids(path):
|
|
2300
|
+
if path not in cache:
|
|
2301
|
+
try: t = open(path, errors="replace").read()
|
|
2302
|
+
except OSError: cache[path] = None; return None
|
|
2303
|
+
cache[path] = set(re.findall(r'<a id="([^"]+)"', t))
|
|
2304
|
+
return cache[path]
|
|
2305
|
+
for dp, _, fns in os.walk(root):
|
|
2306
|
+
if any(p in dp for p in (".git", "node_modules")): continue
|
|
2307
|
+
for fn in fns:
|
|
2308
|
+
if not fn.endswith(".md"): continue
|
|
2309
|
+
src = os.path.join(dp, fn)
|
|
2310
|
+
for rel, frag in re.findall(r'\]\((\.{1,2}/[A-Za-z0-9_./-]+\.md)#([A-Za-z0-9_-]+)\)',
|
|
2311
|
+
open(src, errors="replace").read()):
|
|
2312
|
+
tgt = os.path.normpath(os.path.join(dp, rel)); have = ids(tgt)
|
|
2313
|
+
if have is not None and frag not in have:
|
|
2314
|
+
print("FAIL: %s -> %s#%s (no such anchor)" % (os.path.relpath(src, root), rel, frag)); bad = 1
|
|
2315
|
+
sys.exit(bad)
|
|
2316
|
+
PYEOF
|
|
2317
|
+
|
|
2318
|
+
# 5. every decision has an index row
|
|
2319
|
+
idx=$(ls "$DOCS/INDEX.md" 2>/dev/null || true)
|
|
2320
|
+
dec=$(ls "$DOCS/DECISIONS.md" 2>/dev/null || true)
|
|
2321
|
+
if [ -n "$idx" ] && [ -n "$dec" ]; then
|
|
2322
|
+
for d in $(sed -n '/^```/,/^```/!p' "$dec" | grep -ohE '^#+ DEC-[0-9]{3,4}' | grep -oE 'DEC-[0-9]{3,4}'); do
|
|
2323
|
+
grep -q "$d" "$idx" || err "$d has no INDEX row"
|
|
2324
|
+
done
|
|
2325
|
+
fi
|
|
2326
|
+
|
|
2327
|
+
[ "$fail" -eq 0 ] && printf 'OK: documentation consistent.\n'
|
|
2328
|
+
exit "$fail"
|
|
2329
|
+
'''
|
|
2330
|
+
|
|
2078
2331
|
DECISIONS_SEED = """# Decisions
|
|
2079
2332
|
|
|
2080
2333
|
Every settled decision about this project, append-only. A decision is any answer that
|
|
@@ -2160,6 +2413,19 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
|
|
|
2160
2413
|
|
|
2161
2414
|
seed(docs / "DECISIONS.md", DECISIONS_SEED)
|
|
2162
2415
|
seed(root / "AGENTS.md", AGENTS_SEED.format(snapshot=snapshot))
|
|
2416
|
+
if args.full:
|
|
2417
|
+
# The rest of the architecture that keeps documentation LINKED rather than merely present:
|
|
2418
|
+
# a question register that resolves into decisions, an index nobody has to scan the
|
|
2419
|
+
# register to use, a place for facts about two repositories, one definition per entity —
|
|
2420
|
+
# and the gate, because every one of those rots silently without a check that fails.
|
|
2421
|
+
seed(docs / "OPEN_QUESTIONS.md", OPEN_QUESTIONS_SEED)
|
|
2422
|
+
seed(docs / "INDEX.md", INDEX_SEED)
|
|
2423
|
+
seed(docs / "DEPENDENCIES.md", DEPENDENCIES_SEED)
|
|
2424
|
+
seed(docs / "DATA_MODEL.md", DATA_MODEL_SEED)
|
|
2425
|
+
gate = root / "scripts" / "check-docs.sh"
|
|
2426
|
+
seed(gate, CHECK_DOCS_SEED)
|
|
2427
|
+
if gate.exists():
|
|
2428
|
+
gate.chmod(0o755)
|
|
2163
2429
|
|
|
2164
2430
|
for c in created:
|
|
2165
2431
|
print(f" + {c}")
|
|
@@ -2175,6 +2441,124 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
|
|
|
2175
2441
|
return 0
|
|
2176
2442
|
|
|
2177
2443
|
|
|
2444
|
+
def _repo_state(path: Path, label: str, ignore: str) -> list[str]:
|
|
2445
|
+
"""Is this repository clean, and is its work anywhere but here?
|
|
2446
|
+
|
|
2447
|
+
Both halves matter and only one of them is obvious. Uncommitted work is visible to whoever
|
|
2448
|
+
is sitting in front of it; work committed and never pushed is invisible to everyone else
|
|
2449
|
+
while looking finished to its author — the roadmap says done, the test suite is green, and
|
|
2450
|
+
nobody else can fetch a line of it.
|
|
2451
|
+
"""
|
|
2452
|
+
problems: list[str] = []
|
|
2453
|
+
porcelain = git("status", "--porcelain", cwd=path)
|
|
2454
|
+
dirty = [ln for ln in porcelain.split("\n")
|
|
2455
|
+
if ln.strip() and not re.search(ignore, ln.split()[-1] if ln.split() else "")]
|
|
2456
|
+
if dirty:
|
|
2457
|
+
problems.append(f"{label} has uncommitted work: " + ", ".join(d.split()[-1] for d in dirty[:6]))
|
|
2458
|
+
upstream = git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", cwd=path)
|
|
2459
|
+
branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=path)
|
|
2460
|
+
if upstream:
|
|
2461
|
+
ahead = git("rev-list", "--count", f"{upstream}..HEAD", cwd=path) or "0"
|
|
2462
|
+
if ahead != "0":
|
|
2463
|
+
problems.append(f"{label} is {ahead} commit(s) ahead of {upstream} — pushed nowhere")
|
|
2464
|
+
elif branch == "HEAD":
|
|
2465
|
+
# A submodule sits at a detached pointer by design; what matters is that the commit exists
|
|
2466
|
+
# somewhere others can fetch from.
|
|
2467
|
+
if not git("branch", "-r", "--contains", "HEAD", cwd=path):
|
|
2468
|
+
problems.append(f"{label} is at a commit on no remote branch — nobody else can fetch it")
|
|
2469
|
+
else:
|
|
2470
|
+
problems.append(f"{label} ({branch}) tracks no remote — its work cannot be seen by anyone")
|
|
2471
|
+
return problems
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def cmd_finish(args: argparse.Namespace) -> int:
|
|
2475
|
+
"""The gate expressions this plugin's pipeline binding declares, actually executed.
|
|
2476
|
+
|
|
2477
|
+
`check` answers *is this project wired correctly*. This answers *is the work finished*, and
|
|
2478
|
+
they are different questions with different failure modes. The one it exists for is silent by
|
|
2479
|
+
construction: a project of git submodules records each submodule's commit as a pointer in its
|
|
2480
|
+
parent, and moving the submodule does not move the pointer. So the submodule is pushed, its CI
|
|
2481
|
+
is green, its roadmap says `done` — and anyone who clones the parent gets the commit before the
|
|
2482
|
+
work. Nothing in either repository looks wrong on its own; the disagreement only exists between
|
|
2483
|
+
them, which is why nothing had been checking it.
|
|
2484
|
+
"""
|
|
2485
|
+
s = Sync()
|
|
2486
|
+
root = project_root()
|
|
2487
|
+
ok: list[str] = []
|
|
2488
|
+
problems: list[str] = []
|
|
2489
|
+
ignore = r"\.agent-sync/"
|
|
2490
|
+
|
|
2491
|
+
print(f"run {s.rid} · {'gated' if s.gated else 'ungated'}\n")
|
|
2492
|
+
|
|
2493
|
+
# 1. the parent, then every submodule: clean, pushed, and pointed at
|
|
2494
|
+
p = _repo_state(root, root.name, ignore)
|
|
2495
|
+
problems.extend(p)
|
|
2496
|
+
if not p:
|
|
2497
|
+
ok.append(f"{root.name} clean and pushed")
|
|
2498
|
+
|
|
2499
|
+
for line in (git("submodule", "status") or "").split("\n"):
|
|
2500
|
+
if not line.strip():
|
|
2501
|
+
continue
|
|
2502
|
+
prefix, rest = line[0], line[1:].split()
|
|
2503
|
+
sub = rest[1] if len(rest) > 1 else "?"
|
|
2504
|
+
if prefix == "+":
|
|
2505
|
+
recorded = (git("ls-tree", "HEAD", sub) or "").split()
|
|
2506
|
+
problems.append(
|
|
2507
|
+
f"{sub} — the parent points at {recorded[2][:8] if len(recorded) > 2 else '?'}, "
|
|
2508
|
+
f"the submodule is at {rest[0][:8]}. The bump commit is missing")
|
|
2509
|
+
elif prefix == "-":
|
|
2510
|
+
problems.append(f"{sub} is not checked out — a gate run here skips everything it owns")
|
|
2511
|
+
elif prefix == "U":
|
|
2512
|
+
problems.append(f"{sub} has merge conflicts")
|
|
2513
|
+
else:
|
|
2514
|
+
ok.append(f"{sub} pointer current")
|
|
2515
|
+
subp = root / sub
|
|
2516
|
+
if subp.is_dir():
|
|
2517
|
+
sp = _repo_state(subp, sub, ignore)
|
|
2518
|
+
problems.extend(sp)
|
|
2519
|
+
if not sp and prefix == " ":
|
|
2520
|
+
ok.append(f"{sub} clean and pushed")
|
|
2521
|
+
|
|
2522
|
+
# 2. leases. A run that ends holding one blocks the next agent for the whole TTL, and the
|
|
2523
|
+
# holder is a run id nobody can ask about once its session is gone.
|
|
2524
|
+
held = s.held()
|
|
2525
|
+
if held:
|
|
2526
|
+
problems.append("this run still holds " + ", ".join(held) + " — release before you finish")
|
|
2527
|
+
else:
|
|
2528
|
+
ok.append("no lease left held")
|
|
2529
|
+
|
|
2530
|
+
# 3. the declared gates, on request. They are the project's own commands and can be slow, so
|
|
2531
|
+
# running them is opt-in — but a `finish` that never ran them is a claim, not a check.
|
|
2532
|
+
if args.gates:
|
|
2533
|
+
for cmd in s.cfg.get("gates", []):
|
|
2534
|
+
try:
|
|
2535
|
+
r = subprocess.run(cmd, shell=True, cwd=str(root), capture_output=True,
|
|
2536
|
+
text=True, timeout=600)
|
|
2537
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
2538
|
+
problems.append(f"gate `{cmd}` could not run: {exc}")
|
|
2539
|
+
continue
|
|
2540
|
+
if r.returncode == 0:
|
|
2541
|
+
ok.append(f"gate `{cmd}`")
|
|
2542
|
+
else:
|
|
2543
|
+
tail = [ln for ln in (r.stdout + r.stderr).split("\n") if ln.strip()][-3:]
|
|
2544
|
+
problems.append(f"gate `{cmd}` failed: " + " / ".join(tail))
|
|
2545
|
+
|
|
2546
|
+
for line in ok:
|
|
2547
|
+
print(f" \u2713 {line}")
|
|
2548
|
+
for line in problems:
|
|
2549
|
+
print(f" \u2717 {line}")
|
|
2550
|
+
print()
|
|
2551
|
+
if problems:
|
|
2552
|
+
print(f"{len(problems)} problem(s) — this work is not finished. The usual one is a "
|
|
2553
|
+
"submodule commit with no parent bump:")
|
|
2554
|
+
print(' git -C <submodule> push && git add <submodule> && '
|
|
2555
|
+
'git commit -m "chore: bump <name> submodule — <why>"')
|
|
2556
|
+
return 1
|
|
2557
|
+
print(f"finished cleanly ({len(ok)} checks passed) — every repository is clean, pushed, "
|
|
2558
|
+
"and pointed at.")
|
|
2559
|
+
return 0
|
|
2560
|
+
|
|
2561
|
+
|
|
2178
2562
|
def cmd_check(_args: argparse.Namespace) -> int:
|
|
2179
2563
|
"""Validate the whole setup, end to end, and refuse to call a broken one healthy.
|
|
2180
2564
|
|
|
@@ -2360,6 +2744,16 @@ def cmd_check(_args: argparse.Namespace) -> int:
|
|
|
2360
2744
|
except Fail:
|
|
2361
2745
|
pass
|
|
2362
2746
|
|
|
2747
|
+
tracked_state = git("ls-files", "--", STATE_DIR)
|
|
2748
|
+
if tracked_state:
|
|
2749
|
+
problems.append(
|
|
2750
|
+
f"{STATE_DIR}/ is tracked by git ({len(tracked_state.split(chr(10)))} file(s)) — it is "
|
|
2751
|
+
"generated state, the repository is dirty after every tool call, and a committed "
|
|
2752
|
+
"run-id hands this checkout's identity to every clone. "
|
|
2753
|
+
f"Run: git rm -r --cached {STATE_DIR} && commit")
|
|
2754
|
+
else:
|
|
2755
|
+
ok.append(f"{STATE_DIR}/ is not tracked")
|
|
2756
|
+
|
|
2363
2757
|
for line in ok:
|
|
2364
2758
|
print(f" ✓ {line}")
|
|
2365
2759
|
for line in warn:
|
|
@@ -2418,8 +2812,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2418
2812
|
sub.add_parser("setup", help="write the generated snapshot of how this project is wired").set_defaults(fn=cmd_setup)
|
|
2419
2813
|
sub.add_parser("adopt", help="inspect an existing project and propose a config (writes nothing)").set_defaults(fn=cmd_adopt)
|
|
2420
2814
|
sub.add_parser("check", help="validate the whole setup; non-zero if it is not healthy").set_defaults(fn=cmd_check)
|
|
2815
|
+
fi = sub.add_parser("finish", help="is the work finished — every repo clean, pushed and pointed at; no lease held")
|
|
2816
|
+
fi.add_argument("--gates", action="store_true", help="also run the project's declared gate commands")
|
|
2817
|
+
fi.set_defaults(fn=cmd_finish)
|
|
2421
2818
|
sc = sub.add_parser("scaffold", help="create the missing documentation architecture (never overwrites)")
|
|
2422
2819
|
sc.add_argument("--docs-dir", action="store_true", help="put the register under docs/ even if it does not exist yet")
|
|
2820
|
+
sc.add_argument("--full", action="store_true",
|
|
2821
|
+
help="also seed OPEN_QUESTIONS, INDEX, DEPENDENCIES, DATA_MODEL and the docs gate")
|
|
2423
2822
|
sc.set_defaults(fn=cmd_scaffold)
|
|
2424
2823
|
bd = sub.add_parser("board", help="regenerate the read-only board")
|
|
2425
2824
|
bd.add_argument("--mirror", action="store_true",
|