@miller-tech/uap 1.165.0 → 1.168.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.
Files changed (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +47 -2
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/coord.d.ts +1 -1
  5. package/dist/cli/coord.d.ts.map +1 -1
  6. package/dist/cli/coord.js +59 -0
  7. package/dist/cli/coord.js.map +1 -1
  8. package/dist/cli/merge-queue.d.ts +53 -0
  9. package/dist/cli/merge-queue.d.ts.map +1 -0
  10. package/dist/cli/merge-queue.js +239 -0
  11. package/dist/cli/merge-queue.js.map +1 -0
  12. package/dist/cli/worktree.d.ts +69 -1
  13. package/dist/cli/worktree.d.ts.map +1 -1
  14. package/dist/cli/worktree.js +560 -26
  15. package/dist/cli/worktree.js.map +1 -1
  16. package/dist/config/policy-recommendations.d.ts.map +1 -1
  17. package/dist/config/policy-recommendations.js +4 -0
  18. package/dist/config/policy-recommendations.js.map +1 -1
  19. package/dist/coordination/index.d.ts +1 -0
  20. package/dist/coordination/index.d.ts.map +1 -1
  21. package/dist/coordination/index.js +1 -0
  22. package/dist/coordination/index.js.map +1 -1
  23. package/dist/coordination/ownership.d.ts +51 -0
  24. package/dist/coordination/ownership.d.ts.map +1 -0
  25. package/dist/coordination/ownership.js +175 -0
  26. package/dist/coordination/ownership.js.map +1 -0
  27. package/docs/INDEX.md +2 -1
  28. package/docs/guides/PARALLEL_AGENTS.md +209 -0
  29. package/docs/guides/POLICIES.md +1 -0
  30. package/docs/guides/WORKTREE_WORKFLOW.md +5 -2
  31. package/docs/reference/CLI.md +24 -1
  32. package/package.json +1 -1
  33. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  34. package/src/policies/enforcers/branch_freshness.py +134 -0
  35. package/src/policies/enforcers/coord_overlap.py +126 -47
  36. package/src/policies/schemas/policies/branch-freshness.md +81 -0
  37. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  38. package/templates/hooks/coordinate-file.sh +153 -3
  39. package/templates/hooks/session-end.sh +15 -0
  40. package/templates/hooks/session-start.sh +24 -2
  41. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
@@ -0,0 +1,209 @@
1
+ # Parallel Agents: Staying Collision-Free and Current
2
+
3
+ How several agents work one codebase at once without overwriting each other, and
4
+ without drifting away from the branch everything has to merge into.
5
+
6
+ ## The two failure modes
7
+
8
+ Only one of these is what people mean by "merge conflict", and it is the less
9
+ expensive one.
10
+
11
+ | | Concurrent collision | Sequential drift |
12
+ |---|---|---|
13
+ | **What happens** | Two agents hold the same file at the same moment | Agent A lands a change; agent B's branch predates it and was never re-synced, so B edits a stale copy |
14
+ | **Who notices** | Git, loudly, at merge time | Nobody. B's merge silently reverts A's work, or a model "resolves" the conflict by keeping its own version |
15
+ | **Covered by** | Live-agent lock in `coordinate-file.sh` | Everything on this page |
16
+
17
+ This repo currently has **151 worktrees; the worst is ~1242 commits behind
18
+ `origin/master`; 24 hold unmerged commits and 15 have uncommitted files.** A
19
+ worktree created during the audit that produced this system was born 1 commit
20
+ behind, because creation read local `HEAD` and never fetched.
21
+
22
+ Those numbers are the *starting* state, not a solved problem — the layers below
23
+ stop it getting worse and make it visible. Reconciling the existing backlog is a
24
+ `uap worktree hygiene` / `uap worktree prune` job.
25
+
26
+ ## The layers
27
+
28
+ Each layer catches what the one before it cannot. All of them fail **open** — no
29
+ remote, no git, or an unavailable coordination DB always allows the edit.
30
+
31
+ ### 1. Fresh at birth
32
+
33
+ `uap worktree create <slug>` fetches and bases the new branch on
34
+ `origin/<default>`, not on whatever the local checkout happens to have at HEAD.
35
+
36
+ ```bash
37
+ uap worktree create my-feature # branches from the fetched remote tip
38
+ uap worktree create my-feature --from x # explicit base still wins
39
+ uap worktree create my-feature --no-fetch # offline; accepts a possibly stale base
40
+ ```
41
+
42
+ The default branch is resolved from `origin/HEAD`, then `origin/master`, then
43
+ `origin/main`, then the current local branch, with a final literal `master`
44
+ fallback if even that fails.
45
+
46
+ > If your integration branch is neither `master` nor `main` and `origin/HEAD` is
47
+ > unset (common on `--single-branch` clones), resolution falls through to *the
48
+ > branch you are standing on*. Set it once — `git remote set-head origin -a` — or
49
+ > pass `--from` explicitly.
50
+
51
+ ### 2. Fresh while you work
52
+
53
+ ```bash
54
+ uap worktree sync # run from INSIDE a worktree — syncs the cwd
55
+ uap worktree sync --id 117 # a specific worktree, from anywhere
56
+ uap worktree sync --all # every worktree
57
+ ```
58
+
59
+ With no flags it operates on the current directory and does not verify that it is
60
+ a worktree — run it from the main checkout and it merges into whatever branch that
61
+ checkout is on. Worktrees with uncommitted changes are skipped, not merged.
62
+
63
+ Previously the only sync happened at `uap worktree finish` — the most expensive
64
+ possible moment to discover a conflict. Sync early and often instead.
65
+
66
+ ### 3. Blocked from overwriting landed work
67
+
68
+ Before any worktree edit, `coordinate-file.sh` checks whether **that specific
69
+ file** changed on the integration branch since your branch's merge-base. If it
70
+ did, the edit is blocked:
71
+
72
+ ```
73
+ STALE FILE: src/cli/worktree.ts has changed on origin/master since your branch
74
+ point (you are 12 commits behind). Editing this copy risks silently reverting
75
+ work that already landed. Run 'uap worktree sync' first.
76
+ ```
77
+
78
+ Scoped to the single file, so a stale branch touching untouched files keeps
79
+ working — no blanket freeze. The check depends only on git, so it still runs when
80
+ the coordination DB is missing. Its `git fetch` is throttled (default 600s) so it
81
+ never turns every edit into a network round-trip.
82
+
83
+ | Variable | Effect |
84
+ |---|---|
85
+ | `UAP_COORD_DRIFT=warn` | warn instead of block |
86
+ | `UAP_COORD_DRIFT=off` | disable |
87
+ | `UAP_COORD_FETCH_SECONDS` | fetch throttle (default 600) |
88
+
89
+ An in-progress merge, rebase, cherry-pick or revert is exempt — the files you must
90
+ edit to resolve a conflict are by definition the ones that moved upstream.
91
+
92
+ ### 4. Blocked from drifting too far
93
+
94
+ The `branch-freshness` enforcer (`src/policies/enforcers/branch_freshness.py`)
95
+ warns at 50 commits behind and blocks at 200 — the backstop for a branch whose
96
+ whole model of the codebase has gone stale, where the eventual merge is a rewrite
97
+ rather than a merge. Where layer 3 is file-precise, this is branch-coarse.
98
+
99
+ ```
100
+ branch-freshness: this worktree is 1242 commits behind origin/master (limit 200).
101
+ Edits here are being written against a codebase that has moved on, and the merge
102
+ will be a rewrite rather than a merge. Run `uap worktree sync` first.
103
+ ```
104
+
105
+ | Variable | Effect |
106
+ |---|---|
107
+ | `UAP_FRESHNESS_WARN` | advisory threshold (default 50) |
108
+ | `UAP_FRESHNESS_BLOCK` | blocking threshold (default 200) |
109
+ | `UAP_NO_FRESHNESS=1` | disable |
110
+
111
+ Only worktree edits are in scope; the main checkout is governed by
112
+ `worktree-required`. Fails open on a repo with no remote. It is part of the
113
+ `team` policy scenario — `uap policy select` to enable it.
114
+
115
+ The same 200-commit figure drives the `STALE — safe to prune` marker in
116
+ `uap worktree hygiene`, which is a report, not a gate.
117
+
118
+ The 200-commit figure also drives the `STALE — safe to prune` marker in
119
+ `uap worktree hygiene`, which is a report, not a gate.
120
+
121
+ ### 5. Serialized landing
122
+
123
+ Two independently-green PRs can still break each other on merge. That is not
124
+ hypothetical: PR #577 landed a legitimate infra file while a stale test asserting
125
+ its absence lived on master, turning CI red and blocking every version bump.
126
+
127
+ ```bash
128
+ uap merge queue # prints the plan only — never merges without --yes
129
+ uap merge queue --yes # land one at a time, re-syncing impacted PRs after each
130
+ uap merge queue --yes --limit 3
131
+ ```
132
+
133
+ Merging is irreversible and unattended, so **`--yes` is required**; the bare
134
+ command is a plan. At most 10 PRs per run by default (`--limit`). `--force` lands
135
+ PRs whose checks are not green — the CI safety this page argues for, deliberately
136
+ switched off.
137
+
138
+ Priority bands, highest first:
139
+
140
+ | Band | Matches |
141
+ |---|---|
142
+ | 0 | labels containing `p0`, `critical`, `hotfix`, `security` |
143
+ | 1 | labels containing `bug`, `fix`, `p1`; or branch `fix/…`, `hotfix/…` |
144
+ | 2 | everything else |
145
+ | 3 | branch `chore/…`, `docs/…` |
146
+
147
+ Note the docs/chore band matches on **branch name**, not title — a PR titled
148
+ `docs: …` on `feature/x` sorts into band 2. Ties break on smaller diff first, then
149
+ least-recently-updated. After each merge, every PR that touches the same files
150
+ **or the same ownership lane** is re-synced onto the new base before the next
151
+ merge. PRs whose checks are still running are skipped rather than waited on, so a
152
+ second run is often needed to drain the queue.
153
+
154
+ ### 6. Prevention: ownership lanes
155
+
156
+ The layers above react to collisions. Lanes make them avoidable: partition the
157
+ tree so concurrent agents can be pointed at different areas.
158
+
159
+ `.uap-ownership.json` in the repo root — **tracked on purpose**, since a lane map
160
+ that cannot be committed cannot be shared between agents, clones or CI. An
161
+ untracked `.uap/ownership.json` overrides it for per-machine tweaks.
162
+
163
+ ```json
164
+ {
165
+ "lanes": {
166
+ "cli": ["src/cli/**", "src/bin/**"],
167
+ "delivery": ["src/delivery/**"],
168
+ "policy": ["src/policies/**", "policies/**"]
169
+ }
170
+ }
171
+ ```
172
+
173
+ ```bash
174
+ uap coord ownership # show the lane map
175
+ uap coord ownership src/cli/worktree.ts # which lane, and is it held?
176
+ ```
177
+
178
+ Unmapped paths belong to no lane and are never blocked, so a partial map degrades
179
+ to previous behavior rather than freezing work. Lanes feed the merge queue, where
180
+ they catch the semantic conflict that file-overlap misses entirely: two PRs
181
+ editing *different* files in the same module.
182
+
183
+ > Lanes are currently **advisory plus merge-queue input**. The scheduling
184
+ > primitive that would hand agents disjoint lanes automatically
185
+ > (`selectDisjoint`) is exported and tested but has no production caller yet — no
186
+ > scheduler consumes it. Today you consult lanes; nothing assigns by them.
187
+
188
+ ### 7. Hygiene
189
+
190
+ Silent accumulation is how parallel work gets lost.
191
+
192
+ ```bash
193
+ uap worktree hygiene # per-worktree drift, unmerged commits, dirty files
194
+ uap worktree hygiene --brief # one-line advisory (used by the session banner)
195
+ uap worktree prune --older-than 30
196
+ ```
197
+
198
+ The session banner surfaces the advisory automatically when anything needs
199
+ attention, and stays silent otherwise.
200
+
201
+ ## Recommended loop
202
+
203
+ ```bash
204
+ uap worktree create my-feature # fresh base, guaranteed
205
+ # ... work; the drift check blocks you if a file moves under you ...
206
+ uap worktree sync # cheap, do it often
207
+ uap worktree pr <id>
208
+ uap merge queue # land serialized, re-sync the rest
209
+ ```
@@ -64,6 +64,7 @@ Each enforcer guards a specific station of the pipeline. The enforcers in
64
64
  | `worktree_required` | Edit/Write/MultiEdit must target a `.worktrees/` path |
65
65
  | `task_required` | A UAP task must be `in_progress` before mutating work |
66
66
  | `coord_overlap` | Checks for in-flight agent path reservations (parallel-agent overlap) |
67
+ | `branch_freshness` | Worktree edits blocked once the branch drifts too far from the integration branch (warn 50, block 200) |
67
68
  | `delivery_enforcement` | Route substantive coding through `uap deliver` |
68
69
 
69
70
  ### Plan discipline
@@ -43,7 +43,9 @@ uap worktree finish <id> # 4. land: sync, push, merge the PR, then clean u
43
43
  1. **Create** — `create` allocates the next numeric ID from a registry,
44
44
  builds the branch name `feature/NNN-<slug>`, and runs
45
45
  `git worktree add -b <branch> .worktrees/NNN-<slug> <base>`. The base branch
46
- defaults to your current branch (override with `--from`). The new worktree is
46
+ defaults to the freshly-fetched `origin/<default>` not your current branch
47
+ so a worktree is never born stale (override with `--from`, skip the fetch with
48
+ `--no-fetch`). See [Parallel Agents](PARALLEL_AGENTS.md). The new worktree is
47
49
  recorded in a SQLite registry at `.uap/worktree_registry.db` so concurrent
48
50
  `create` calls never race on the same ID.
49
51
  2. **Work** — `cd` into the worktree and make changes. Everything stays on the
@@ -104,7 +106,8 @@ Create a new worktree and feature branch for `<slug>`.
104
106
 
105
107
  | Flag | Description |
106
108
  |------|-------------|
107
- | `-f, --from <branch>` | Base branch (defaults to the current branch) |
109
+ | `-f, --from <branch>` | Base branch (defaults to the fetched `origin/<default>`) |
110
+ | `--no-fetch` | Skip the base fetch (offline; accepts a possibly stale base) |
108
111
  | `-d, --description <description>` | Optional worktree description |
109
112
 
110
113
  ```bash
@@ -250,8 +250,10 @@ worktree under `.worktrees/NNN-<slug>/`.
250
250
 
251
251
  | Subcommand | Key flags | Purpose |
252
252
  |------------|-----------|---------|
253
- | `create <slug>` | `-f, --from <branch>`, `-d, --description` | Create a new worktree for a feature |
253
+ | `create <slug>` | `-f, --from <branch>`, `--no-fetch`, `-d, --description` | Create a worktree, based on the fetched `origin/<default>` |
254
254
  | `list` | — | List all worktrees |
255
+ | `sync` | `-i, --id <id>`, `-a, --all` | Merge the integration branch into a worktree (mid-flight re-base) |
256
+ | `hygiene` | `-b, --brief` | Report drift, unmerged work, and stale worktrees |
255
257
  | `pr <id>` | `--draft` | Create a PR from a worktree |
256
258
  | `finish <id>` | — | Sync, merge PR, and auto-cleanup the worktree |
257
259
  | `cleanup <id>` | — | Remove a worktree and delete its branch |
@@ -261,12 +263,33 @@ worktree under `.worktrees/NNN-<slug>/`.
261
263
  ```bash
262
264
  uap worktree ensure --strict
263
265
  uap worktree create add-user-auth
266
+ uap worktree sync # re-base this worktree mid-flight
267
+ uap worktree hygiene # drift + unmerged work across all worktrees
264
268
  uap worktree finish 042
265
269
  uap worktree prune --older-than 14 --dry-run
266
270
  ```
267
271
 
268
272
  ---
269
273
 
274
+ ## `merge`
275
+
276
+ Serialized landing of concurrent agent PRs. See
277
+ [Parallel Agents](../guides/PARALLEL_AGENTS.md).
278
+
279
+ | Subcommand | Key flags | Purpose |
280
+ |------------|-----------|---------|
281
+ | `queue` | `-y, --yes`, `-n, --dry-run`, `-l, --limit <n>` (10), `--force` | Land PRs one at a time, re-syncing every PR each merge invalidates |
282
+
283
+ Without `--yes` the command only prints its plan — merging is irreversible and
284
+ unattended. `--force` lands PRs whose checks are not green.
285
+
286
+ ```bash
287
+ uap merge queue # plan only
288
+ uap merge queue --yes --limit 3
289
+ ```
290
+
291
+ ---
292
+
270
293
  ## `sync`
271
294
 
272
295
  Sync configuration between platforms.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.165.0",
3
+ "version": "1.168.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """branch-freshness enforcer: a worktree branch must not drift far from the
3
+ integration branch before it is allowed to keep accumulating edits.
4
+
5
+ The file-level companion (coordinate-file.sh) blocks precisely: only when the
6
+ FILE being edited moved on the integration branch. That is the right default —
7
+ it never freezes work that cannot conflict. This enforcer is the coarse backstop
8
+ for the other failure mode: a branch so far behind that its whole mental model of
9
+ the codebase is wrong, where the eventual merge is a rewrite rather than a merge.
10
+
11
+ Measured on this repo before the gate existed: 151 worktrees, the worst 1241
12
+ commits behind origin/master, 23 holding unmerged commits. None of that drift was
13
+ visible to any gate.
14
+
15
+ Env:
16
+ UAP_NO_FRESHNESS=1 disable entirely
17
+ UAP_FRESHNESS_WARN=<n> advisory threshold (default 50)
18
+ UAP_FRESHNESS_BLOCK=<n> blocking threshold (default 200)
19
+ """
20
+ from __future__ import annotations
21
+ import os
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ sys.path.insert(0, str(Path(__file__).parent))
26
+ from _common import emit, parse_cli, run, worktree_root # noqa: E402
27
+
28
+ EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
29
+
30
+ DEFAULT_WARN = 50
31
+ DEFAULT_BLOCK = 200
32
+
33
+
34
+ def _int_env(name: str, default: int) -> int:
35
+ raw = os.environ.get(name)
36
+ if not raw:
37
+ return default
38
+ try:
39
+ value = int(raw)
40
+ except ValueError:
41
+ return default
42
+ return value if value > 0 else default
43
+
44
+
45
+ def integration_ref(root: Path) -> str | None:
46
+ """origin/HEAD -> origin/master -> origin/main, or None when there is no remote."""
47
+ code, out, _ = run(["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], cwd=root)
48
+ if code == 0 and out.strip():
49
+ return out.strip().replace("refs/remotes/", "")
50
+ for candidate in ("master", "main"):
51
+ code, _, _ = run(
52
+ ["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{candidate}"],
53
+ cwd=root,
54
+ )
55
+ if code == 0:
56
+ return f"origin/{candidate}"
57
+ return None
58
+
59
+
60
+ def commits_behind(root: Path, ref: str) -> int | None:
61
+ code, out, _ = run(["git", "rev-list", "--count", f"HEAD..{ref}"], cwd=root)
62
+ if code != 0:
63
+ return None
64
+ try:
65
+ return int(out.strip())
66
+ except ValueError:
67
+ return None
68
+
69
+
70
+ def main() -> None:
71
+ op, args = parse_cli()
72
+ if op not in EDIT_OPS:
73
+ emit(True, "not a file-edit operation")
74
+
75
+ if os.environ.get("UAP_NO_FRESHNESS") == "1":
76
+ emit(True, "UAP_NO_FRESHNESS override set")
77
+
78
+ target = args.get("file_path") or args.get("path") or args.get("target") or ""
79
+ if not target:
80
+ emit(True, "no file path in args")
81
+
82
+ # Only worktree edits are in scope. Edits in the main checkout are governed by
83
+ # worktree-required, and non-repo paths are none of our business.
84
+ if ".worktrees/" not in str(Path(target)):
85
+ emit(True, "not a worktree edit")
86
+
87
+ root = worktree_root()
88
+ # A linked worktree has .git as a FILE, not a directory — both are valid.
89
+ git_marker = root / ".git"
90
+ if not git_marker.exists():
91
+ emit(True, "not a git working tree — fail open")
92
+
93
+ ref = integration_ref(root)
94
+ if ref is None:
95
+ emit(True, "no integration ref (no remote) — fail open")
96
+
97
+ behind = commits_behind(root, ref)
98
+ if behind is None:
99
+ emit(True, "drift not measurable — fail open")
100
+
101
+ warn_at = _int_env("UAP_FRESHNESS_WARN", DEFAULT_WARN)
102
+ block_at = _int_env("UAP_FRESHNESS_BLOCK", DEFAULT_BLOCK)
103
+ # A warn threshold above the block threshold would silently never fire.
104
+ if warn_at > block_at:
105
+ warn_at = block_at
106
+
107
+ if behind >= block_at:
108
+ emit(
109
+ False,
110
+ f"branch-freshness: this worktree is {behind} commits behind {ref} "
111
+ f"(limit {block_at}). Edits here are being written against a codebase that "
112
+ "has moved on, and the merge will be a rewrite rather than a merge. "
113
+ "Run `uap worktree sync` first. Override: UAP_NO_FRESHNESS=1.",
114
+ route="worktree-sync",
115
+ worktreeHint="uap worktree sync",
116
+ behind=behind,
117
+ )
118
+
119
+ if behind >= warn_at:
120
+ emit(
121
+ True,
122
+ f"branch-freshness: {behind} commits behind {ref} — consider "
123
+ "`uap worktree sync` before this grows into a conflict.",
124
+ behind=behind,
125
+ )
126
+
127
+ # Report `behind` on EVERY path, not just warn/block — callers and tests read
128
+ # it for observability, and a field that appears only on failure is a field
129
+ # nobody can build a dashboard on.
130
+ emit(True, f"fresh ({behind} behind {ref})", behind=behind)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
@@ -1,6 +1,29 @@
1
1
  #!/usr/bin/env python3
2
- """coord-overlap enforcer: check for in-flight agent path reservations."""
2
+ """coord-overlap enforcer: don't spawn an agent onto paths a LIVE agent holds.
3
+
4
+ Rewritten. The previous implementation was effectively decorative:
5
+
6
+ * it scraped candidate paths out of the raw prompt STRING by splitting on
7
+ whitespace and keeping tokens containing "/", so "see src/foo.ts for
8
+ context" registered as a reservation target while a structured `paths`
9
+ list was the only input that ever worked properly;
10
+ * it then iterated EVERY table in the coordination DB looking for a column
11
+ coincidentally named path/paths/file/scope and substring-matched against it,
12
+ so an unrelated table could veto a spawn;
13
+ * it never consulted agent liveness, so a reservation left behind by an agent
14
+ that died weeks ago blocked new work forever.
15
+
16
+ This version queries the real `work_announcements` schema, joins
17
+ `agent_registry` for heartbeat liveness (matching coordinate-file.sh's
18
+ semantics), and only considers paths it can defend as actual targets.
19
+
20
+ Env:
21
+ UAP_NO_COORD_OVERLAP=1 disable
22
+ UAP_COORD_LIVE_SECONDS=<n> liveness window, shared with coordinate-file.sh (default 120)
23
+ """
3
24
  from __future__ import annotations
25
+ import os
26
+ import re
4
27
  import sqlite3
5
28
  import sys
6
29
  from pathlib import Path
@@ -8,73 +31,129 @@ from pathlib import Path
8
31
  sys.path.insert(0, str(Path(__file__).parent))
9
32
  from _common import emit, parse_cli, repo_root # noqa: E402
10
33
 
11
- AGENT_OPS = {"Agent", "spawn-agent", "subagent", "delegate"}
34
+ AGENT_OPS = {"Agent", "spawn-agent", "subagent", "delegate", "task"}
35
+
36
+ DEFAULT_LIVE_SECONDS = 120
37
+
38
+ # A path-ish token: has a separator, a file extension or a known source dir, and
39
+ # no spaces. Deliberately conservative — a false positive here BLOCKS work.
40
+ PATH_TOKEN = re.compile(
41
+ r"(?:^|[\s'\"`(])((?:src|test|tests|lib|scripts|docs|policies|templates)/[\w./-]+"
42
+ r"|[\w./-]+\.(?:ts|tsx|js|jsx|py|sh|md|json|yaml|yml|sql))(?:[\s'\"`),:;]|$)"
43
+ )
44
+
45
+
46
+ def _live_seconds() -> int:
47
+ raw = os.environ.get("UAP_COORD_LIVE_SECONDS")
48
+ if not raw:
49
+ return DEFAULT_LIVE_SECONDS
50
+ try:
51
+ value = int(raw)
52
+ except ValueError:
53
+ return DEFAULT_LIVE_SECONDS
54
+ return value if value > 0 else DEFAULT_LIVE_SECONDS
55
+
56
+
57
+ def candidate_paths(args: dict) -> list[str]:
58
+ """Paths this spawn is going to work on.
59
+
60
+ Prefers STRUCTURED input (`paths`, `scope`, `files`). Falls back to a
61
+ conservative regex over the prompt, which is a hint, not a contract.
62
+ """
63
+ for key in ("paths", "scope", "files"):
64
+ raw = args.get(key)
65
+ if isinstance(raw, list) and raw:
66
+ return [str(p).strip() for p in raw if str(p).strip()]
67
+ if isinstance(raw, str) and raw.strip():
68
+ return [p for p in re.split(r"[\s,]+", raw.strip()) if p]
69
+
70
+ prompt = args.get("prompt") or args.get("description") or ""
71
+ if not isinstance(prompt, str):
72
+ return []
73
+ return sorted({m.group(1) for m in PATH_TOKEN.finditer(prompt)})
12
74
 
13
75
 
14
- def overlapping_reservations(root: Path, paths: list[str]) -> list[str]:
76
+ def live_holders(root: Path, paths: list[str]) -> list[tuple[str, str]]:
77
+ """(holder, resource) pairs for paths held by a LIVE agent other than us."""
15
78
  db = root / "agents" / "data" / "coordination" / "coordination.db"
16
79
  if not db.exists() or not paths:
17
80
  return []
81
+
82
+ me = os.environ.get("UAP_AGENT_ID") or ""
83
+ window = _live_seconds()
84
+ hits: list[tuple[str, str]] = []
85
+
18
86
  try:
19
87
  con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
20
- # Best-effort: look for any reservations table with path-like column
21
- cur = con.execute(
22
- "SELECT name FROM sqlite_master WHERE type='table'"
23
- )
24
- tables = [r[0] for r in cur.fetchall()]
25
- hits: list[str] = []
26
- for t in tables:
27
- try:
28
- cols = [r[1] for r in con.execute(f"PRAGMA table_info({t})")]
29
- path_col = next(
30
- (c for c in cols if c.lower() in ("path", "paths", "file", "scope")),
31
- None,
32
- )
33
- status_col = next(
34
- (c for c in cols if c.lower() in ("status", "state", "active")), None
35
- )
36
- if not path_col:
37
- continue
38
- where = f" WHERE {status_col} IN ('active','in_progress',1)" if status_col else ""
39
- rows = con.execute(f"SELECT {path_col} FROM {t}{where}").fetchall()
40
- for (v,) in rows:
41
- if not v:
42
- continue
43
- for p in paths:
44
- if p and p in str(v):
45
- hits.append(f"{t}:{v}")
46
- except sqlite3.Error:
47
- continue
48
- con.close()
49
- return hits
50
88
  except sqlite3.Error:
51
89
  return []
52
90
 
91
+ try:
92
+ # Schema guard: if work_announcements is absent this DB predates the
93
+ # coordination system — fail open rather than guessing at other tables.
94
+ exists = con.execute(
95
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='work_announcements'"
96
+ ).fetchone()
97
+ if not exists:
98
+ return []
99
+
100
+ rows = con.execute(
101
+ """
102
+ SELECT wa.resource,
103
+ COALESCE(wa.agent_name, wa.agent_id) AS holder,
104
+ wa.agent_id
105
+ FROM work_announcements wa
106
+ LEFT JOIN agent_registry ar ON ar.id = wa.agent_id
107
+ WHERE wa.completed_at IS NULL
108
+ AND COALESCE(ar.status, 'active') = 'active'
109
+ AND (strftime('%s','now')
110
+ - strftime('%s', COALESCE(ar.last_heartbeat, wa.announced_at))) < ?
111
+ """,
112
+ (window,),
113
+ ).fetchall()
114
+ except sqlite3.Error:
115
+ return []
116
+ finally:
117
+ con.close()
118
+
119
+ for resource, holder, agent_id in rows:
120
+ if not resource or (me and agent_id == me):
121
+ continue
122
+ res = str(resource)
123
+ for p in paths:
124
+ # Match on path containment in EITHER direction: reserving a
125
+ # directory covers files under it, and reserving a file conflicts
126
+ # with a spawn scoped to its directory.
127
+ if res == p or res.startswith(p.rstrip("/") + "/") or p.startswith(res.rstrip("/") + "/"):
128
+ hits.append((str(holder), res))
129
+ break
130
+
131
+ return hits
132
+
53
133
 
54
134
  def main() -> None:
55
135
  op, args = parse_cli()
56
136
  if op not in AGENT_OPS:
57
137
  emit(True, "not an agent-spawn op")
58
138
 
59
- paths_raw = (
60
- args.get("paths")
61
- or args.get("scope")
62
- or args.get("prompt", "")
63
- )
64
- if isinstance(paths_raw, list):
65
- paths = [str(p) for p in paths_raw]
66
- else:
67
- paths = [p for p in str(paths_raw).split() if "/" in p]
68
-
69
- hits = overlapping_reservations(repo_root(), paths)
139
+ if os.environ.get("UAP_NO_COORD_OVERLAP") == "1":
140
+ emit(True, "UAP_NO_COORD_OVERLAP override set")
141
+
142
+ paths = candidate_paths(args)
143
+ if not paths:
144
+ emit(True, "no resolvable target paths")
145
+
146
+ hits = live_holders(repo_root(), paths)
70
147
  if hits:
148
+ detail = ", ".join(f"{holder} holds {res}" for holder, res in hits[:5])
71
149
  emit(
72
150
  False,
73
- f"coord-overlap: active reservations on: {', '.join(hits[:5])}. "
74
- "Run `uap coordination check` before spawning.",
151
+ f"coord-overlap: {detail}. Spawning onto the same paths risks two agents "
152
+ "diverging on one file. Narrow the spawn's scope, wait for them to finish, "
153
+ "or check `uap coord board`. Override: UAP_NO_COORD_OVERLAP=1.",
75
154
  )
76
155
 
77
- emit(True, "no overlapping reservations")
156
+ emit(True, f"no live holders for {len(paths)} path(s)")
78
157
 
79
158
 
80
159
  if __name__ == "__main__":