@ssheleg/agent-sync 1.4.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.4.2"
7
+ version: "1.7.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -43,8 +43,8 @@ coordination plane, where `status` shows it to every agent without anyone fetchi
43
43
  branch. Committed to a branch, a claim is invisible until the merge and turns the shared
44
44
  roadmap into a file two branches both edit. Land work with `merge`: conflicts computed by
45
45
  `git merge-tree` **before anything is touched**, named and refused if any, the merge
46
- recorded in `docs/MERGES.md` (recent days in full, older compacted on write), the lease
47
- released. `merges` tells the next agent what landed while it was away. **Read
46
+ recorded in `docs/MERGES.md` (recent days in full, older compacted on write), the `--key`
47
+ lease released. `merges` tells the next agent what landed while it was away. **Read
48
48
  `references/branching.md`** before merging.
49
49
 
50
50
  **3. Hooks exist only in Claude Code.** Elsewhere nothing blocks a guarded edit: run
@@ -97,10 +97,8 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" adopt
97
97
  Confirm the registers and guarded files with the operator first: a register pointed at the
98
98
  wrong file makes every later check confidently wrong, and a guarded list that misses a
99
99
  shared file leaves the one place collisions happen unprotected. In a submodule it declares
100
- no registers: decisions belong to the parent repository.
101
-
102
- Then: `init` → paste the approved config → `reconcile --set-baseline` → `setup` →
103
- commit the snapshot and link it from the project's agent instructions.
100
+ no registers: decisions belong to the parent repository. Then take the chain above from
101
+ `init`.
104
102
 
105
103
  ## First command in a project: `init`
106
104
 
@@ -147,7 +145,8 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" status
147
145
  ```
148
146
 
149
147
  Idempotent. Inspects, repairs what is missing, prints a status block, names exactly
150
- ONE next action.
148
+ ONE next action — and carries `check`'s verdict, so the command every session runs and
149
+ the command that validates the setup cannot give two answers about one project.
151
150
 
152
151
  **Read the two awareness sections it prints — they are the point, not decoration.**
153
152
 
@@ -191,7 +190,7 @@ npx sshlg-skills install
191
190
  | `whoami` | Print this run's id and its held leases |
192
191
  | `setup` | Write the generated snapshot of how **this** project is wired, for agents to read |
193
192
  | `adopt` | Inspect an existing project and **propose** a config — writes nothing |
194
- | `merge` | Land this branch: conflicts checked **before** anything is touched, merge log written, lease released. `--key`, `--summary`, `--dry-run`, `--push` |
193
+ | `merge` | Land this branch: local target fast-forwarded, conflicts checked **before** anything is touched, merge log written, the `--key` lease released. `--summary`, `--dry-run`, `--push` |
195
194
  | `merges` | What landed while you were on your branch. `--all` includes the compacted tail |
196
195
  | `check` | Validate the whole setup end to end. Non-zero when it is not healthy |
197
196
  | `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 |
@@ -214,10 +213,8 @@ shared.
214
213
 
215
214
  The third matters because a plain shell command has no session id and a hook does. So
216
215
  `SessionStart` stamps `.agent-sync/sessions/<CLI pid>` with the session it knows, and a later
217
- command finds itself by walking its own process ancestry to a stamped pid exact, and
218
- deliberately not command-line parsing: the throwaway shell every tool call runs in carries
219
- claude paths in its argv, so every heuristic aimed at the binary matched it instead. Stamps
220
- are removed when their process is gone.
216
+ command finds itself by walking its own process ancestry to a stamped pid. Why that and not
217
+ command-line parsing: `references/earned-rules.md`.
221
218
 
222
219
  When none of the four can be established the run says so — *"this identity is shared with any other
223
220
  session in this checkout"* — rather than presenting a shared entry as separation.
@@ -246,8 +243,11 @@ The config lists registry files several agents write. Before editing one:
246
243
  python3 "$SKILL_DIR/scripts/agent_sync.py" guard docs/DECISIONS.md
247
244
  ```
248
245
 
249
- Exit 2 means another run holds it. Do not edit anyway, and do not "just fix one line"
250
- a clobbered decision looks exactly like a decision.
246
+ **Exit 2 is about *this run*: it holds no lease** not that somebody else holds that
247
+ file. One lease covers every guarded file; hold one or write none. A denial names the
248
+ other run **and its key**, because "r-x holds a lease" beside a path gets repeated as
249
+ "r-x holds this file". Do not edit anyway, and do not "just fix one line" — a clobbered
250
+ decision looks exactly like a decision.
251
251
 
252
252
  Claude Code's `PreToolUse` hook runs this for you. Elsewhere nothing does.
253
253
 
@@ -260,9 +260,9 @@ both use it.
260
260
  python3 "$SKILL_DIR/scripts/agent_sync.py" reserve DEC # → DEC-0216
261
261
  ```
262
262
 
263
- Allocation is positional over the log, so every agent computes the same answer. Reserve
264
- and not write it to git? `release-id` it — otherwise the number is a hole the board
265
- reports as a leak, and nobody can tell a hole from work on a branch.
263
+ Allocation is positional over the **merged** log every shard, never just this run's
264
+ so every agent computes the same answer. Reserved and not written to git? `release-id`
265
+ it, or the number is a hole the board reports as a leak.
266
266
 
267
267
  ## Nothing in a log is ever edited or deleted
268
268
 
@@ -304,7 +304,7 @@ which side a document belongs on.
304
304
  This skill supplies stages; it does not define them. Stage names are
305
305
  `task-pipeline`'s own.
306
306
 
307
- Four of the eleven stages carry a rule the others do not, and each is about ordering:
307
+ Five of the eleven stages carry a rule the others do not, and each is about ordering:
308
308
  **0** `acquire` before the brief is committed; **1** `reconcile` and resolve every
309
309
  divergence before writing code; **3** `reserve` every id before it reaches git; **9**
310
310
  `record`, `signal`, `reconcile`, `board` — the main write point. **10** ends the run:
@@ -368,12 +368,6 @@ The mirror is a **rendering** of git, stamped with the source commit. It has no
368
368
  authority. When its stamp and `HEAD` disagree, the board gate fails — that is
369
369
  drift, not a formatting problem.
370
370
 
371
- ## Two rules, and the failures that taught them
372
-
373
- Identity comes before coordination: a lease is only a lease if two agents get two identities. A
374
- submodule commit is unfinished until its parent points at it. Both:
375
- [`references/earned-rules.md`](references/earned-rules.md).
376
-
377
371
  ## Non-negotiables
378
372
 
379
373
  - Append, read back, then act. Never rewrite a coordination document.
@@ -399,6 +393,7 @@ Each file is loaded on its own trigger, not by default.
399
393
  | `references/two-sources.md` | before the first reconcile, or when deciding where a document belongs |
400
394
  | `references/roadmap.md` | configuring `claimTags`, taking or closing a task, or re-planning a board |
401
395
  | `references/branching.md` | starting work that will produce commits, merging a branch, or asking what landed while you were away |
396
+ | `references/earned-rules.md` | asking why identity resolves the way it does, or why `finish` exists |
402
397
 
403
398
  If this copy arrived without `references/`, fetch them from
404
399
  `https://raw.githubusercontent.com/ssheleg/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
@@ -45,8 +45,15 @@ In order, and every check before anything is touched:
45
45
 
46
46
  1. **Refuses a detached HEAD, the integration branch itself, and a dirty tree.** A merge
47
47
  cannot tell uncommitted work from the branch's own commits.
48
- 2. **Fetches the integration branch** and reports how far it moved since the branch
49
- started the number that predicts how stale the work is.
48
+ 2. **Fetches the integration branch and fast-forwards the local one to it**, so the
49
+ preflight and the merge share a base, then reports how far it moved since the branch
50
+ started. Until 1.6.0 they did not share one: conflicts and the diff were measured
51
+ against `origin/<target>` while the merge went into a local `<target>` nothing
52
+ advanced — so `merge` printed the staleness it had just measured, printed `✓ merged`,
53
+ wrote the log entry and released the lease, and the push was rejected. The work had not
54
+ landed, the log said it had, and the task was free for somebody else. A local branch
55
+ that has *diverged* (commits on both sides) cannot be fast-forwarded and is refused
56
+ with both counts.
50
57
  3. **Computes conflicts with `git merge-tree`**, in memory. A merge that starts and then
51
58
  aborts leaves the operator in a repository they did not ask for. On conflict it names
52
59
  the files, changes nothing, and exits non-zero: resolve in your own branch, where the
@@ -55,8 +62,14 @@ In order, and every check before anything is touched:
55
62
  the same files, they merge into what is about to land.
56
63
  5. Merges `--no-ff`, so the branch stays visible in history.
57
64
  6. **Writes the merge log** and commits it.
58
- 7. **Releases every lease this run holds.** A run that ends holding one blocks the next
59
- agent for the whole TTL.
65
+ 7. **Releases the lease named by `--key`.** Only that one: releasing every lease the run
66
+ holds is a different statement from the one this step makes, and it quietly frees work
67
+ that has not landed. Without `--key` there is nothing to name, so it releases what the
68
+ run holds and says so. A run that ends holding a lease blocks the next agent for the
69
+ whole TTL — so release, but release what you landed.
70
+
71
+ The remote is `origin`, the conventional home of the integration branch. `leaseRemote` is a
72
+ different setting for a different job: where the lease refs live.
60
73
 
61
74
  `--dry-run` stops after step 4. `--push` pushes the integration branch afterwards;
62
75
  without it, `finish` is the next call — it checks every repository, not just this one.
@@ -13,6 +13,15 @@ belonged to somebody else, and `release` would have taken it. The resolution ord
13
13
  `AGENT_SYNC_RUN_ID` · `CLAUDE_SESSION_ID` · the session that started this shell · shared, and where
14
14
  none can be established the run says so instead of presenting a shared entry as separation.
15
15
 
16
+ The third position is the one that needs explaining, because the obvious implementation of it is
17
+ wrong. A plain shell command carries no session id; a hook does. So `SessionStart` stamps
18
+ `.agent-sync/sessions/<CLI pid>` with the session it knows, and any later command finds itself by
19
+ walking its own **process ancestry** to a stamped pid, removing stamps whose process is gone. It is
20
+ deliberately not command-line parsing: the throwaway shell every tool call runs in carries claude
21
+ paths in its own `argv`, so every heuristic aimed at spotting the CLI binary matched that shell
22
+ instead — and matching the wrong ancestor hands two sessions one identity, which is the failure
23
+ above.
24
+
16
25
  **Work in a submodule is not finished until its parent says so.** A parent records each submodule as
17
26
  a pointer to one commit, and moving the submodule does not move the pointer. The work is committed,
18
27
  pushed, green in CI and marked done in its own roadmap — and a clone of the parent gets the commit
@@ -46,7 +46,7 @@ The hook receives JSON on stdin with `session_id`, `prompt_id`, `transcript_path
46
46
  "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" }] }
47
47
  ],
48
48
  "PreToolUse": [
49
- { "matcher": "Edit|Write|MultiEdit",
49
+ { "matcher": "Edit|Write|MultiEdit|NotebookEdit",
50
50
  "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }] },
51
51
  { "matcher": "Bash", "if": "Bash(git commit *)",
52
52
  "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }] }
@@ -66,8 +66,8 @@ The hook receives JSON on stdin with `session_id`, `prompt_id`, `transcript_path
66
66
  |---|---|
67
67
  | `session-start.sh` | Register the run, print the board summary and the one next action |
68
68
  | `guard.sh` | Deny an edit to a `guardedFiles[]` path, or a commit staging one, without a live lease |
69
- | `renew.sh` | Renew the lease, throttled to `renewIntervalSeconds` a no-op most calls |
70
- | `session-end.sh` | Release every lease, flush the journal, close the run |
69
+ | `renew.sh` | Renew the lease — moves the timestamp expiry is computed from, throttled to `renewIntervalSeconds`, a no-op most calls |
70
+ | `session-end.sh` | Release every lease this run holds. That is all it does — it writes no journal entry and closes nothing else |
71
71
 
72
72
  ## Performance
73
73
 
@@ -82,7 +82,7 @@ throttle is broken — fix the throttle rather than removing the hook.
82
82
  |---|---|
83
83
  | Guarded edits go through | The guard crashed. Any exit code other than 2 is non-blocking. Run it by hand with a sample stdin payload |
84
84
  | Everything is denied | No config, or no lease. `status` says which |
85
- | Session start is slow | The backend is unreachable; it should time out at 5 s and degrade, not hang |
85
+ | Session start is slow | The backend is unreachable. Each hook is capped twice `run_limited 10` inside the script, and the `timeout` in `hooks.json` (15–20 s) — so it degrades rather than hanging |
86
86
  | Renew floods the log | The throttle file is not being written — check its path is writable |
87
87
 
88
88
  Run the guard directly to see what it decides:
@@ -42,20 +42,37 @@ tests for the exact bullet you emitted skips malformed lines *before* they can b
42
42
  counted, so the ratio reads 0% while nothing parses — the guard and the counter both
43
43
  go quiet at once. This is not hypothetical; it is how the bug above stayed invisible.
44
44
 
45
- **An unreadable log is not a lost race.** When more than 2% of a log fails to parse,
46
- `acquire` **raises** instead of reporting `lost`, and the board gate fails. Reporting
47
- a lost race would name a holder who does not exist and send the caller looking for
48
- them.
45
+ **An unreadable log is not a lost race.** Past 2% unparseable, **every command that
46
+ replays a log refuses it** the read itself fails, so `status`, `board`, `check`,
47
+ `reconcile` and `reserve` stop and name the ratio instead of acting on a partial
48
+ history. Replaying it would report holders who do not exist and silence where the real
49
+ ones are, and both of those look exactly like an answer.
50
+
51
+ `acquire` is not in that list, and the reason is the design: the lease is decided by the
52
+ lock file or the git ref, never by the log, so a corrupt log cannot make a lease look
53
+ lost. Until 1.6.0 the threshold was a constant nothing read — declared, quoted in three
54
+ documents, and implemented nowhere except a warning line on the board that returned zero.
49
55
 
50
56
  ## Acquiring — the third design, and the first that is true
51
57
 
52
58
  ```
53
- 1. reap if .agent-sync/leases/<K>.lock exists and is expired, remove it
54
- 2. create os.open(lock, O_CREAT | O_EXCL) this is the decision, and it is atomic
55
- 3. lost FileExistsError -> read the holder out of the file and report it
56
- 4. won write {run, ts, ttl, repo}; publish op=acquire to the plane for visibility
59
+ 1. free os.open(lock, O_CREAT | O_EXCL) — this is the decision, and it is atomic
60
+ 2. held live holder -> report it; this run -> already ours
61
+ 3. expired take the steal section, re-read expiry inside it, then reap and create
62
+ 4. lost FileExistsError, or the section is held -> read the holder and report it
63
+ 5. won write {run, ts, ttl, repo}; publish op=acquire to the plane for visibility
57
64
  ```
58
65
 
66
+ **Step 3 is one critical section, not two calls.** `unlink` followed by `O_EXCL create`
67
+ leaves a gap, and a second stealer that has already read the lock as expired removes the
68
+ lock the first one just created — both then hold what each believes is exclusive. Twelve
69
+ racing processes never showed it; a 300 ms delay injected between the two calls produced
70
+ two winners out of two, and in production that delay is an ordinary scheduler hiccup. So
71
+ `<K>.lock.steal` is created with `O_EXCL`, the expiry is re-read **inside** it (the holder
72
+ may have renewed, or another stealer may have finished), and it carries its own 30-second
73
+ abandonment grace — without one, a crash between two filesystem calls would cost the key
74
+ until somebody deleted a file nobody documents.
75
+
59
76
  **Publishing is not the decision.** A failure to reach the knowledge base costs
60
77
  visibility, never correctness: the lock is already held. So the append is wrapped and
61
78
  its failure reported, not raised.
@@ -86,16 +103,29 @@ The tool reports which guarantee is in force; it never implies the stronger one.
86
103
 
87
104
  ## Expiry and stealing
88
105
 
89
- A lock is expired when `now > ts + ttl` for the timestamp inside it, refreshed by
90
- `renew`.
106
+ A lock is expired when `now > ts + ttl` for the timestamp inside it.
107
+
108
+ **`renew` moves that timestamp, in the plane that arbitrates the lease** — it rewrites the
109
+ lock file in `local` mode, and re-pushes the ref with `--force-with-lease` against the
110
+ exact object it read in `git` mode. The `op=renew` line it also appends to the record plane
111
+ is visibility, not renewal.
112
+
113
+ That distinction is the whole of the bug fixed in 1.5.3: `renew` wrote *only* the record
114
+ line. The lock's `ts` was written once, by `acquire`, so a run holding a lease lost it at
115
+ TTL while still working — its own guard began denying it, and another run acquired the task
116
+ it was in the middle of. Nothing reported it, because from the record plane's side the
117
+ renewals were arriving exactly as scheduled. **A renewal that does not move the timestamp
118
+ the expiry is computed from is not a renewal**, however faithfully it is logged.
91
119
 
92
120
  Default `ttl` is 2700 s (45 minutes). `renew` is emitted at most once per
93
121
  `renewIntervalSeconds` (default 300 s) — by the `PostToolUse` hook in Claude Code,
94
- and by the agent itself everywhere else.
122
+ and by the agent itself everywhere else. A `renew` for a key this run does not hold
123
+ refreshes nothing and says so.
95
124
 
96
- **Stealing an expired lease is the ordinary `acquire` path.** There is no force flag:
97
- the reap step removes an expired lock and the create proceeds. The steal is visible on
98
- the plane with both run ids, so an operator can see that it happened and when.
125
+ **Stealing an expired lease is the ordinary `acquire` path.** There is no force flag: the
126
+ steal section above removes an expired lock and creates the new one, as one operation. The
127
+ steal is visible on the plane with both run ids, so an operator can see that it happened
128
+ and when.
99
129
 
100
130
  ## Releasing
101
131
 
@@ -120,7 +150,14 @@ Then, replaying in order and maintaining a free list:
120
150
  - `op=reserve key=DEC` takes the free-list head if it is non-empty; otherwise it
121
151
  takes `base + (count of prior reserves not served from the free list)`.
122
152
 
123
- Every reader computes the same assignment for every reserve line, including its own.
153
+ Every reader computes the same assignment for every reserve line, including its own
154
+ **and "the log" means every shard merged, never the one this run writes.** Reading only
155
+ its own document is how `reserve` handed three runs `DEC-0007` three times (fixed in
156
+ 1.5.3): each replayed a log containing only its own lines, each seeded its own `base`
157
+ from the register, and each was correct about a history nobody else shared. The failure
158
+ is the same one that disqualified per-writer documents as a *lease* store, arriving in
159
+ the allocator — so a `base` now only ever moves allocation **forward**, and two runs
160
+ opening a register in the same minute cannot restart each other's count.
124
161
 
125
162
  **An id you reserved and did not write to git must be released** with
126
163
  `release_id`. An id that is reserved, unreleased and absent from git after its run
@@ -133,7 +170,7 @@ number, and silently handing it out again would produce two documents with one i
133
170
  | Fact | Home | Lifetime |
134
171
  |---|---|---|
135
172
  | Who holds this task **right now** | the lease log | ephemeral, TTL |
136
- | Who **owns** this task | the git claim tag (`[name]`, `todo (claimed: <role>)`) | durable |
173
+ | Who **owns** this task | the git claim tag `todo (claimed: r-7f3a91)`, the template from `claimTags.held` | durable |
137
174
 
138
175
  `acquire` writes the git tag through and `release` restores exactly what was there. The
139
176
  objection that once demoted this to a check — an unattended process rewriting a shared
@@ -5,12 +5,29 @@
5
5
  `agent-sync` supplies stages; it does not define them. The stage names below are
6
6
  `task-pipeline`'s own — do not rename, renumber or fork them.
7
7
 
8
+ ## The numbers, once
9
+
10
+ <!-- agent-sync:stages rules=0,1,3,9,10 wired=0,1,3,4,5,9,10 -->
11
+
12
+ Two different questions, and answering them in one list is why three documents once gave
13
+ three answers:
14
+
15
+ - **Stages carrying a rule — 0, 1, 3, 9, 10.** Something must happen there or the run is
16
+ wrong, and each rule is about ordering. Quoted in `SKILL.md`.
17
+ - **Stages wired into `pipeline.json` — 0, 1, 3, 4, 5, 9, 10.** The list above plus the two
18
+ where the journal has teeth (file ownership, submodule pointers). Quoted in the README.
19
+
20
+ `SKILL.md` said "four of the eleven stages" and then listed five; the README named a third
21
+ set; and this file called stage 1 *"nothing shared to coordinate"* — the stage `reconcile`
22
+ belongs to. The marker above is the source, and the validator fails when a surface stops
23
+ agreeing with it.
24
+
8
25
  ## Where it plugs in
9
26
 
10
27
  | Stage | Calls | Why there and not elsewhere |
11
28
  |---|---|---|
12
29
  | **0 Intake grill** | `status`, then `acquire <KEY>` | The cloud KB and the board join the harvest's source ledger. The lease is taken **before the brief is committed**, or two agents write two briefs for one task |
13
- | **1 Docs study** | | External docs; nothing shared to coordinate |
30
+ | **1 Docs study** | `reconcile`, then resolve every divergence | The git documents say how it *should* be, the as-built record how it *is*. Building on an unresolved divergence is writing code against a system that does not exist — and this is the last stage where that costs nothing |
14
31
  | **2 Brainstorm + decompose** | `journal` | Also warns when a live run holds an overlapping key — cheapest moment to find the overlap |
15
32
  | **3 Spec** | `reserve <REG>` per id | Ids must be reserved *before* they are written to git. Reading "next free id" is not reserving it |
16
33
  | **4 Plan** | `journal` with the plan's file ownership | Parallel groups that write one file are a merge conflict scheduled for later |
@@ -24,30 +41,68 @@
24
41
  ## pipeline.json
25
42
 
26
43
  `task-pipeline`'s `pipeline.schema.json` already permits this; nothing is forked.
27
- Add `agent-sync` to `skills[]` on the six stages that call it:
44
+ Add `agent-sync` to `skills[]` on the six stages that call it, and append its
45
+ clause to each of those stages' existing `gate.check`:
28
46
 
29
47
  ```json
30
48
  {
31
49
  "stages": [
32
- { "id": "0", "title": "Intake grill", "skills": ["task-pipeline:grill", "agent-sync"],
33
- "gate": { "type": "manual", "check": "brief committed and lease held" } },
34
- { "id": "3", "title": "Spec", "skills": ["task-pipeline:spec", "agent-sync"],
35
- "gate": { "type": "auto", "check": "every id in the spec was reserved" } },
36
- { "id": "4", "title": "Plan", "skills": ["task-pipeline:planning", "agent-sync"],
37
- "gate": { "type": "auto", "check": "no two parallel tasks write one file" } },
38
- { "id": "5", "title": "Dev", "skills": ["task-pipeline:build", "agent-sync"],
39
- "gate": { "type": "auto", "check": "lease live and submodule pointers current" } },
40
- { "id": "9", "title": "Docs + wiki", "skills": ["task-pipeline:artifacts", "agent-sync"],
41
- "gate": { "type": "auto", "check": "board regenerated and no mirror drift" } },
42
- { "id": "10", "title": "Acceptance", "skills": ["task-pipeline:acceptance", "agent-sync"],
43
- "gate": { "type": "auto", "check": "every lease released and every claim tag written through" } }
50
+ { "id": 0, "state": "intake", "name": "Intake grill",
51
+ "skills": ["task-pipeline:grill", "agent-sync"],
52
+ "gate": { "type": "manual", "check": "<the stage's own criteria> AND the lease for this task is held before the brief is committed" } },
53
+ { "id": 1, "state": "docs-study", "name": "Docs study",
54
+ "skills": ["task-pipeline:knowledge", "agent-sync"],
55
+ "gate": { "type": "manual", "check": "<the stage's own criteria> AND `reconcile` ran and every divergence it named is resolved or recorded as standing" } },
56
+ { "id": 3, "state": "spec", "name": "Spec",
57
+ "skills": ["task-pipeline:spec", "agent-sync"],
58
+ "gate": { "type": "manual", "check": "<the stage's own criteria> AND every id the spec writes was reserved first" } },
59
+ { "id": 4, "state": "plan", "name": "Plan",
60
+ "skills": ["task-pipeline:planning", "agent-sync"],
61
+ "gate": { "type": "auto", "check": "<the stage's own criteria> AND no two parallel tasks write one file" } },
62
+ { "id": 5, "state": "dev", "name": "Dev",
63
+ "skills": ["task-pipeline:build", "agent-sync"],
64
+ "gate": { "type": "auto", "check": "<the stage's own criteria> AND the lease is live and submodule pointers are current" } },
65
+ { "id": 9, "state": "docs-wiki", "name": "Docs + wiki",
66
+ "skills": ["task-pipeline:documentation", "task-pipeline:gates", "agent-sync"],
67
+ "gate": { "type": "auto", "check": "<the stage's own criteria — the propagation sweep and a green documentation gate with its ratchets printed> AND the board is regenerated with no mirror drift" } },
68
+ { "id": 10, "state": "acceptance", "name": "Acceptance",
69
+ "skills": ["task-pipeline:acceptance", "agent-sync"],
70
+ "gate": { "type": "manual", "check": "<the stage's own criteria> AND every lease is released and every claim tag written through" } }
44
71
  ]
45
72
  }
46
73
  ```
47
74
 
48
- Stages 1, 2, 6, 7 and 8 keep their own `skills[]`; `agent-sync` only journals there,
75
+ **Three things in that JSON are contract, not style.** `state` is **required** by
76
+ `pipeline.schema.json`, `id` is an **integer**, and the human label is `name` — not
77
+ `title`. This example carried `"id": "0"` with a `title` and no `state` until
78
+ 2026-08-03, while claiming the schema permitted it; anyone who copied it got a config
79
+ `task-pipeline` rejects.
80
+
81
+ **And the gate text EXTENDS, never replaces.** Each `check` above is
82
+ *`<the stage's own criteria>` **AND** agent-sync's clause* — written out that way on
83
+ purpose. An earlier version of this file stated only agent-sync's half, so a host
84
+ that copied it silently dropped the stage's real gate: stage 9 lost the propagation
85
+ sweep and the documentation gate, stage 10 lost the ladder walk and the evidence
86
+ rule.
87
+
88
+ Stages 2, 6, 7 and 8 keep their own `skills[]`; `agent-sync` only journals there,
49
89
  which needs no wiring.
50
90
 
91
+ ## What must be guarded
92
+
93
+ `guardedFiles` is every shared file two agents could write in the same minute — and
94
+ since the pipeline's documentation track it is longer than the registers:
95
+
96
+ | File | Why it is shared state |
97
+ |---|---|
98
+ | the decision register (`docs/DECISIONS.md` or `docs/adr/`) | append-only; a concurrent write loses an entry |
99
+ | `docs/OPEN_QUESTIONS.md`, `docs/ROADMAP.md` | the same |
100
+ | **`docs/DOCMAP.md`** | seeded into every project the pipeline touches; holds the registers, the propagation matrix and the ratchet floors. Losing it loses the map |
101
+ | **`docs/superpowers/retro.md`** | capped at ten standing instructions, so a concurrent write silently **drops a lesson** instead of conflicting visibly |
102
+
103
+ The schema keeps `agent-sync.json` to known keys, which is why this reasoning lives
104
+ here and not as a comment in the config.
105
+
51
106
  ## Preflight
52
107
 
53
108
  `task-pipeline` is required. When it is absent, print the install line and **stop** —