@miller-tech/uap 1.165.0 → 1.169.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 (47) 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/dist/delivery/self-gate.js +6 -0
  28. package/dist/delivery/self-gate.js.map +1 -1
  29. package/dist/delivery/verifier-ladder.d.ts +16 -0
  30. package/dist/delivery/verifier-ladder.d.ts.map +1 -1
  31. package/dist/delivery/verifier-ladder.js +20 -3
  32. package/dist/delivery/verifier-ladder.js.map +1 -1
  33. package/docs/INDEX.md +2 -1
  34. package/docs/guides/PARALLEL_AGENTS.md +209 -0
  35. package/docs/guides/POLICIES.md +1 -0
  36. package/docs/guides/WORKTREE_WORKFLOW.md +5 -2
  37. package/docs/reference/CLI.md +24 -1
  38. package/package.json +1 -1
  39. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  40. package/src/policies/enforcers/branch_freshness.py +134 -0
  41. package/src/policies/enforcers/coord_overlap.py +126 -47
  42. package/src/policies/schemas/policies/branch-freshness.md +81 -0
  43. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  44. package/templates/hooks/coordinate-file.sh +153 -3
  45. package/templates/hooks/session-end.sh +15 -0
  46. package/templates/hooks/session-start.sh +24 -2
  47. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
@@ -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__":
@@ -0,0 +1,81 @@
1
+ # Policy: Branch Freshness
2
+
3
+ **ID**: `policy-branch-freshness`
4
+ **Name**: Work Against the Latest Integration Branch
5
+ **Category**: workflow
6
+ **Level**: REQUIRED
7
+ **Enforcement Stage**: pre-exec
8
+ **Version**: 1.0
9
+
10
+ ## Purpose
11
+
12
+ Multiple agents working the same codebase in parallel lose each other's work in two
13
+ distinct ways. Only one of them is a "merge conflict".
14
+
15
+ 1. **Concurrent collision** — two agents hold the same file at the same moment.
16
+ Git surfaces this loudly at merge time. Handled by `coordinate-file.sh`'s
17
+ live-agent lock.
18
+ 2. **Sequential drift** — agent A lands a change; agent B's branch was cut before
19
+ that and never re-synced, so B edits a stale copy. B's merge silently reverts
20
+ A's work, or forces a conflict resolution that a model resolves by picking its
21
+ own version. Git does NOT warn about this. This is the expensive one.
22
+
23
+ This policy addresses (2): work must be based on, and kept close to, the current
24
+ integration branch.
25
+
26
+ ## Rules
27
+
28
+ ```rules
29
+ - title: "Fresh Base"
30
+ keywords: ["worktree", "branch", "create", "start work", "new feature"]
31
+ antiPatterns: ["branch from stale local", "no fetch before branching", "based on local master", "detached stale base"]
32
+
33
+ - title: "Stale File Guard"
34
+ keywords: ["edit", "write", "modify", "change file"]
35
+ antiPatterns: ["edit file changed upstream", "ignore upstream change", "overwrite landed work", "revert merged change"]
36
+
37
+ - title: "Drift Ceiling"
38
+ keywords: ["edit", "write", "worktree", "long-running branch"]
39
+ antiPatterns: ["hundreds of commits behind", "never synced", "stale worktree", "abandoned branch still edited"]
40
+ ```
41
+
42
+ ## Enforcement Behavior
43
+
44
+ ### When Triggered
45
+
46
+ - **Worktree creation** — `uap worktree create` fetches and bases the new branch on
47
+ `origin/<default>` rather than whatever the local checkout has at HEAD.
48
+ - **Every worktree file edit** — two independent checks:
49
+ - *file-precise* (`coordinate-file.sh`): blocks when the specific file being
50
+ edited has changed on the integration branch since this branch's merge-base.
51
+ - *branch-coarse* (`branch_freshness.py`): warns at 50 commits behind, blocks at 200.
52
+
53
+ ### Required Actions
54
+
55
+ 1. Cut branches from the fetched remote tip, never from a stale local ref.
56
+ 2. Before editing a file that moved upstream, run `uap worktree sync` and re-apply
57
+ the change on top of the landed version.
58
+ 3. Keep a branch within the drift ceiling for its whole life; sync routinely rather
59
+ than once at `finish`, when resolution is most expensive.
60
+ 4. Land PRs through `uap merge queue` so each merge re-syncs the PRs it impacts.
61
+
62
+ ### Overrides
63
+
64
+ | Variable | Effect |
65
+ |---|---|
66
+ | `UAP_COORD_DRIFT=warn` | file-precise check warns instead of blocking |
67
+ | `UAP_COORD_DRIFT=off` | disable the file-precise check |
68
+ | `UAP_NO_FRESHNESS=1` | disable the branch-coarse check |
69
+ | `UAP_FRESHNESS_WARN` / `UAP_FRESHNESS_BLOCK` | tune the drift thresholds |
70
+ | `uap worktree create --no-fetch` | branch offline, accepting a possibly stale base |
71
+
72
+ All checks fail **open**: no remote, no git, or an unreadable coordination DB
73
+ allows the edit. Coordination being unavailable must never stop work.
74
+
75
+ ## Rationale
76
+
77
+ Measured on this repository before these gates existed: 151 worktrees, the worst
78
+ 1241 commits behind `origin/master`, 23 holding unmerged commits and 15 with
79
+ uncommitted files. A worktree created during that audit was born 1 commit behind
80
+ because creation read local `HEAD` and never fetched. None of that drift was
81
+ visible to any gate, and none of it produced a warning at edit time.
@@ -11,7 +11,9 @@
11
11
  # different worktrees collides (that IS the future merge conflict).
12
12
  #
13
13
  # Exit 0 = allow (no conflict, or only a stale/self-healed announcement → warn).
14
- # Exit 2 = BLOCK: another LIVE agent (heartbeat < THRESHOLD) holds this file.
14
+ # Exit 2 = BLOCK: another LIVE agent (heartbeat < THRESHOLD) holds this file, OR
15
+ # the file has MOVED on the integration branch since this branch's
16
+ # merge-base (editing it now would silently revert landed work).
15
17
  #
16
18
  # Always fails OPEN: any missing dependency or DB error allows the edit. This
17
19
  # hook must never break editing because coordination is unavailable.
@@ -27,10 +29,158 @@ ABS="${6:-}"
27
29
  # Seconds since another agent's last heartbeat for it to count as "live".
28
30
  THRESHOLD="${UAP_COORD_LIVE_SECONDS:-120}"
29
31
 
30
- # Fail open on any missing prerequisite.
32
+ # ---------------------------------------------------------------------------
33
+ # Merge-base drift check (sequential-overwrite protection).
34
+ #
35
+ # The live-lock below only covers CONCURRENT edits — two agents holding the same
36
+ # file at the same moment. The far more common loss is SEQUENTIAL: agent A landed
37
+ # a change to foo.ts an hour ago; agent B's worktree was cut before that and never
38
+ # re-synced, so B edits a stale copy and its merge quietly reverts A's work.
39
+ #
40
+ # So: if THIS exact file changed on the integration branch since our merge-base,
41
+ # block until the agent syncs. Scoped to the single file being edited, so a stale
42
+ # worktree touching untouched files keeps working — no blanket freeze.
43
+ #
44
+ # UAP_COORD_DRIFT=block|warn|off (default block)
45
+ # UAP_COORD_FETCH_SECONDS=<n> throttle for the background fetch (default 600)
46
+ # ---------------------------------------------------------------------------
47
+ DRIFT_MODE="${UAP_COORD_DRIFT:-block}"
48
+ FETCH_THROTTLE="${UAP_COORD_FETCH_SECONDS:-600}"
49
+
50
+ # Validate every numeric knob before it reaches SQL or arithmetic. THRESHOLD is
51
+ # interpolated into the liveness SQL below; a non-numeric value there produced a
52
+ # silent SQL error, which fails the query open and disables the lock entirely.
53
+ case "$THRESHOLD" in ''|*[!0-9]*) THRESHOLD=120 ;; esac
54
+ case "$FETCH_THROTTLE" in ''|*[!0-9]*) FETCH_THROTTLE=600 ;; esac
55
+
56
+ # Bound the git calls in check_drift. Only the fetch was bounded before, so a
57
+ # slow merge-base/diff on a huge repo or a stalled network FS could hang the
58
+ # agent's Edit tool with no diagnostic. macOS has no `timeout` by default.
59
+ if command -v timeout >/dev/null 2>&1; then
60
+ DRIFT_TIMEOUT="timeout -k 1 10"
61
+ elif command -v gtimeout >/dev/null 2>&1; then
62
+ DRIFT_TIMEOUT="gtimeout -k 1 10"
63
+ else
64
+ DRIFT_TIMEOUT=""
65
+ fi
66
+
67
+ [ -n "$ME" ] && [ -n "$REL" ] || exit 0
68
+
69
+ # Resolve the integration ref (origin/HEAD → origin/master → origin/main).
70
+ integration_ref() {
71
+ local d="$1" r
72
+ r=$(git -C "$d" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null) && {
73
+ printf '%s' "${r#refs/remotes/}"; return 0; }
74
+ for c in master main; do
75
+ git -C "$d" rev-parse --verify --quiet "refs/remotes/origin/$c" >/dev/null 2>&1 && {
76
+ printf 'origin/%s' "$c"; return 0; }
77
+ done
78
+ return 1
79
+ }
80
+
81
+ check_drift() {
82
+ [ "$DRIFT_MODE" = "off" ] && return 0
83
+ command -v git >/dev/null 2>&1 || return 0
84
+ [ -n "$ABS" ] || return 0
85
+
86
+ local dir; dir="${ABS%/*}"
87
+ [ -d "$dir" ] || return 0
88
+
89
+ # Anchor EVERY git call to the working tree that actually contains the file.
90
+ # Running them from the file's own directory was a latent no-op: git resolves a
91
+ # pathspec against the process prefix, so `-C <wt>/src/cli ... -- src/cli/x.ts`
92
+ # looked for <wt>/src/cli/src/cli/x.ts and matched nothing. Drift then "passed"
93
+ # for every file below the repo root — i.e. all real source — silently and
94
+ # fail-open. It only appeared to work for files sitting at the root, which is
95
+ # exactly what the first round of tests used.
96
+ local top; top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || return 0
97
+ [ -n "$top" ] || return 0
98
+
99
+ # Never fight an in-progress merge/rebase: the conflict markers the agent has to
100
+ # edit are, by definition, in files that moved upstream. Blocking there deadlocks
101
+ # the very resolution `uap worktree sync` just asked for.
102
+ local gitdir; gitdir=$(git -C "$top" rev-parse --absolute-git-dir 2>/dev/null) || return 0
103
+ for marker in MERGE_HEAD REBASE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do
104
+ [ -e "$gitdir/$marker" ] && return 0
105
+ done
106
+ [ -d "$gitdir/rebase-merge" ] || [ -d "$gitdir/rebase-apply" ] && return 0
107
+
108
+ # Path relative to THIS working tree, derived here rather than trusting the
109
+ # caller's REL — which is relative to the agent's cwd and is wrong whenever the
110
+ # agent stands in the main checkout while editing into a worktree.
111
+ local rel="${ABS#"$top"/}"
112
+ [ "$rel" != "$ABS" ] || return 0
113
+
114
+ local ref; ref=$(integration_ref "$top") || return 0
115
+
116
+ # Throttled fetch: an edit-time network call on EVERY keystroke-scale edit would
117
+ # be intolerable, so refresh at most once per FETCH_THROTTLE seconds. Between
118
+ # refreshes we compare against the last known remote tip — still catches the
119
+ # overwhelming majority of drift, and `uap worktree sync` refreshes on demand.
120
+ local common; common=$(git -C "$top" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
121
+ || common=$(git -C "$top" rev-parse --git-common-dir 2>/dev/null) || return 0
122
+ case "$common" in /*) ;; *) common="$top/$common" ;; esac
123
+ # If the stamp dir is not real, DISABLE the fetch rather than the throttle —
124
+ # otherwise a bad path silently means "fetch on every single edit".
125
+ if [ -d "$common" ]; then
126
+ local stamp="$common/.uap-drift-fetch"
127
+ local now; now=$(date +%s)
128
+ local last=0
129
+ [ -f "$stamp" ] && read -r last < "$stamp" 2>/dev/null
130
+ case "$last" in ''|*[!0-9]*) last=0 ;; esac
131
+ # A future-dated stamp (clock skew, or a stray write) would otherwise wedge
132
+ # fetching off indefinitely.
133
+ [ "$last" -gt "$now" ] 2>/dev/null && last=0
134
+ # mkdir is atomic on POSIX and NFS: with many agents sharing one common dir,
135
+ # a read-compare-write throttle lets every agent in the window fetch at once.
136
+ if [ $((now - last)) -ge "$FETCH_THROTTLE" ] && mkdir "$stamp.lock" 2>/dev/null; then
137
+ printf '%s' "$now" > "$stamp.tmp.$$" 2>/dev/null &&
138
+ mv -f "$stamp.tmp.$$" "$stamp" 2>/dev/null || true
139
+ # Detached: nothing in THIS edit depends on the fetch landing — we compare
140
+ # against the last known tip either way. Redirections are load-bearing; a
141
+ # background job holding the hook's stdout makes the harness wait on it.
142
+ (
143
+ GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND='ssh -oBatchMode=yes -oConnectTimeout=5' \
144
+ $DRIFT_TIMEOUT git -C "$top" fetch -q origin "${ref#origin/}" >/dev/null 2>&1
145
+ rmdir "$stamp.lock" 2>/dev/null
146
+ ) </dev/null >/dev/null 2>&1 &
147
+ fi
148
+ # Reap a lock orphaned by a killed agent (older than 10 min).
149
+ if [ -d "$stamp.lock" ]; then
150
+ local lockage; lockage=$(find "$stamp.lock" -maxdepth 0 -mmin +10 2>/dev/null || true)
151
+ [ -n "$lockage" ] && rmdir "$stamp.lock" 2>/dev/null
152
+ fi
153
+ fi
154
+
155
+ local mb; mb=$($DRIFT_TIMEOUT git -C "$top" merge-base HEAD "$ref" 2>/dev/null) || return 0
156
+ [ -n "$mb" ] || return 0
157
+
158
+ # Did this specific path change on the integration branch since we branched?
159
+ # diff-tree (plumbing) skips the work-tree setup and index read that `git diff`
160
+ # performs, and ":(top)" pins the pathspec to the repo root regardless of cwd.
161
+ local moved; moved=$($DRIFT_TIMEOUT git -C "$top" diff-tree -r --name-only --no-renames \
162
+ "$mb" "$ref" -- ":(top)$rel" 2>/dev/null)
163
+ [ -n "$moved" ] || return 0
164
+
165
+ local n; n=$($DRIFT_TIMEOUT git -C "$top" rev-list --count "$mb..$ref" 2>/dev/null || echo '?')
166
+
167
+ if [ "$DRIFT_MODE" = "warn" ]; then
168
+ echo "COORDINATION WARNING: ${rel} changed on ${ref} since your branch point (${n} commits behind). Run 'uap worktree sync' before editing or your merge may revert landed work." >&2
169
+ return 0
170
+ fi
171
+
172
+ echo "{\"decision\":\"block\",\"reason\":\"STALE FILE: ${rel} has changed on ${ref} since your branch point (you are ${n} commits behind). Editing this copy risks silently reverting work that already landed. Run 'uap worktree sync' first, then re-apply your change on top. Override: UAP_COORD_DRIFT=warn.\"}" >&2
173
+ return 2
174
+ }
175
+
176
+ # 0) Sequential-drift check FIRST. It depends only on git, never on the
177
+ # coordination DB — a missing/unwritable DB must not silently disable
178
+ # overwrite protection (it did: the DB prerequisite used to exit 0 above it).
179
+ check_drift || exit 2
180
+
181
+ # The announcement half needs sqlite3 and a real DB file; fail open without them.
31
182
  command -v sqlite3 >/dev/null 2>&1 || exit 0
32
183
  [ -n "$DB" ] && [ -f "$DB" ] || exit 0
33
- [ -n "$ME" ] && [ -n "$REL" ] || exit 0
34
184
 
35
185
  # SQL-escape single quotes.
36
186
  q() { printf '%s' "${1:-}" | sed "s/'/''/g"; }
@@ -50,6 +50,21 @@ if [ -f "$COORD_DB" ]; then
50
50
  OR (strftime('%s','now') - strftime('%s', last_heartbeat)) >= $STALE_SECS
51
51
  );
52
52
  " 2>/dev/null || true
53
+
54
+ # Release THIS agent's own holds immediately. The stale sweep above only frees
55
+ # an agent once its heartbeat has been dead for STALE_SECS — so a session that
56
+ # ended cleanly seconds after an edit kept its files locked against every peer
57
+ # for the full window, and peers saw a "live agent" that had already exited.
58
+ # We know our own id, so there is no reason to wait for it to look dead.
59
+ if [ -n "${UAP_AGENT_ID:-}" ]; then
60
+ ME_Q=$(printf '%s' "$UAP_AGENT_ID" | sed "s/'/''/g")
61
+ sqlite3 "$COORD_DB" "
62
+ UPDATE work_announcements SET completed_at = '$TIMESTAMP'
63
+ WHERE completed_at IS NULL AND agent_id = '$ME_Q';
64
+ UPDATE agent_registry SET status = 'completed'
65
+ WHERE id = '$ME_Q';
66
+ " 2>/dev/null || true
67
+ fi
53
68
  fi
54
69
 
55
70
  # Clean up backup files older than 7 days (retention policy)
@@ -146,9 +146,27 @@ fi
146
146
  GIT_BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null || echo "?")
147
147
  GIT_DIRTY=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
148
148
 
149
+ # .worktrees/ lives in the MAIN checkout, not in a linked worktree — so when the
150
+ # session starts INSIDE a worktree (the normal case under the worktree policy),
151
+ # PROJECT_DIR has no .worktrees/ and the count silently read 0. COORD_ROOT is
152
+ # already resolved from git-common-dir, i.e. the main checkout; reuse it.
149
153
  WORKTREE_COUNT=0
150
- if [ -d "${PROJECT_DIR}/.worktrees" ]; then
151
- WORKTREE_COUNT=$(find "${PROJECT_DIR}/.worktrees" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
154
+ if [ -d "${COORD_ROOT}/.worktrees" ]; then
155
+ WORKTREE_COUNT=$(find "${COORD_ROOT}/.worktrees" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
156
+ fi
157
+
158
+ # Worktree drift advisory. A count of worktrees says nothing about whether any of
159
+ # them still hold work — silent accumulation is exactly how parallel-agent work
160
+ # gets lost. Measured here before this line existed: 151 worktrees, worst 1241
161
+ # commits behind origin/master, 23 holding unmerged commits. Purely advisory and
162
+ # time-boxed, so a slow git call can never delay session start.
163
+ WORKTREE_DRIFT=""
164
+ if [ "$WORKTREE_COUNT" -gt 0 ] && command -v uap >/dev/null 2>&1; then
165
+ if command -v timeout >/dev/null 2>&1; then
166
+ WORKTREE_DRIFT=$(cd "$COORD_ROOT" 2>/dev/null && timeout 15 uap worktree hygiene --brief 2>/dev/null || true)
167
+ else
168
+ WORKTREE_DRIFT=$(cd "$COORD_ROOT" 2>/dev/null && uap worktree hygiene --brief 2>/dev/null || true)
169
+ fi
152
170
  fi
153
171
 
154
172
  PATTERN_COUNT=0
@@ -200,6 +218,10 @@ output+="- Worktree gate: run uap worktree ensure --strict (or uap worktree crea
200
218
  output+="- Backup gate: copy files to .uap-backups/$(date +%Y-%m-%d)/ before modification."$'\n'
201
219
  output+="- Coordination: agent=${AGENT_ID}; announce work, check overlaps, complete announcement when done."$'\n'
202
220
  output+="- Validation: run relevant build/tests for changed code before finalizing."$'\n'
221
+ if [ -n "$WORKTREE_DRIFT" ]; then
222
+ output+="- ${WORKTREE_DRIFT}"$'\n'
223
+ output+="- Freshness: branch from the fetched remote tip; \`uap worktree sync\` before editing a file that moved upstream."$'\n'
224
+ fi
203
225
  output+=""$'\n'
204
226
  output+="</system-reminder>"$'\n\n'
205
227