collab-runtime 0.4.2__tar.gz → 0.5.1__tar.gz

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 (31) hide show
  1. {collab_runtime-0.4.2/collab_runtime.egg-info → collab_runtime-0.5.1}/PKG-INFO +18 -1
  2. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/README.md +34 -12
  3. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/agent_identity.py +52 -5
  4. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/dashboard/index.html +138 -11
  5. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/dashboard_server.py +2 -0
  6. collab_runtime-0.5.1/collab/githooks.py +318 -0
  7. collab_runtime-0.5.1/collab/hook_templates/commit-msg +21 -0
  8. collab_runtime-0.5.1/collab/hook_templates/post-commit +30 -0
  9. collab_runtime-0.5.1/collab/hook_templates/pre-commit +75 -0
  10. collab_runtime-0.5.1/collab/hook_templates/pre-push +58 -0
  11. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/live_locks_watcher.py +116 -2
  12. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/lock_client.py +69 -7
  13. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/main.py +97 -2
  14. {collab_runtime-0.4.2 → collab_runtime-0.5.1/collab_runtime.egg-info}/PKG-INFO +18 -1
  15. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab_runtime.egg-info/SOURCES.txt +5 -0
  16. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/docs/pypi/README.md +17 -0
  17. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/pyproject.toml +4 -2
  18. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/LICENSE +0 -0
  19. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/__init__.py +0 -0
  20. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/__main__.py +0 -0
  21. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/dashboard/dashboard-format.js +0 -0
  22. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/errors.py +0 -0
  23. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/logging_config.py +0 -0
  24. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/platform_probe.py +0 -0
  25. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/safe_subprocess.py +0 -0
  26. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab/subprocess_bridge.py +0 -0
  27. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab_runtime.egg-info/dependency_links.txt +0 -0
  28. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab_runtime.egg-info/entry_points.txt +0 -0
  29. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab_runtime.egg-info/requires.txt +0 -0
  30. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/collab_runtime.egg-info/top_level.txt +0 -0
  31. {collab_runtime-0.4.2 → collab_runtime-0.5.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -130,6 +130,19 @@ collab active
130
130
 
131
131
  If connected, this lists all currently active locks (empty on a fresh setup).
132
132
 
133
+ ### 4 — Install Git Hooks (optional)
134
+
135
+ ```bash
136
+ collab init-hooks
137
+ ```
138
+
139
+ This installs `pre-commit`, `post-commit`, `pre-push`, and `commit-msg` hooks into the current
140
+ repository. The hooks acquire locks for staged files, block commits that conflict with another
141
+ developer's lock, and release locks after a successful push. They resolve the project `.venv` first,
142
+ so **commits from VS Code / Cursor Source Control behave the same as a venv-activated terminal**.
143
+
144
+ Existing non-collab hooks are preserved; pass `--force` to overwrite them.
145
+
133
146
  ---
134
147
 
135
148
  ## CLI Reference
@@ -162,6 +175,10 @@ collab force-release-all
162
175
  collab acquire-batch path/to/a.py path/to/b.py --reason "Refactoring"
163
176
  collab release-batch path/to/a.py path/to/b.py
164
177
 
178
+ # Install git hooks into the current repo (offline)
179
+ collab init-hooks
180
+ collab init-hooks --force
181
+
165
182
  # Reconcile local and remote lock state
166
183
  collab reconcile
167
184
 
@@ -74,15 +74,18 @@ The setup script automatically:
74
74
 
75
75
  After setup, verify your `.env` at the project root has these values:
76
76
 
77
- | Variable | Description |
78
- | --------------------------- | ---------------------------------------------------------------- |
79
- | `SUPABASE_URL` | Your Supabase project URL (from Project Settings → API) |
80
- | `SUPABASE_ANON_KEY` | Anonymous/public key (from Project Settings → API) |
81
- | `SUPABASE_SERVICE_ROLE_KEY` | Service role key (**required** for dashboard force-release) |
82
- | `LOCK_STRICT` | If `1`, git hooks block on lock errors. Default `0` (warn only) |
83
- | `COLLAB_AGENT_ID` | Optional stable id for an AI agent session (multi-agent locking) |
84
- | `COLLAB_AGENT_LABEL` | Optional display label (e.g. `refactor-auth`) |
85
- | `COLLAB_AGENT_MODE` | Set to `1` to auto-generate/persist an agent id when unset |
77
+ | Variable | Description |
78
+ | --------------------------- | ------------------------------------------------------------------- |
79
+ | `SUPABASE_URL` | Your Supabase project URL (from Project Settings → API) |
80
+ | `SUPABASE_ANON_KEY` | Anonymous/public key (from Project Settings → API) |
81
+ | `SUPABASE_SERVICE_ROLE_KEY` | Service role key (**required** for dashboard force-release) |
82
+ | `LOCK_STRICT` | If `1`, git hooks block on lock errors. Default `0` (warn only) |
83
+ | `COLLAB_AGENT_ID` | Optional stable id for an AI agent session (multi-agent locking) |
84
+ | `COLLAB_AGENT_LABEL` | Optional task label shown on the dashboard (e.g. `refactor-auth`) |
85
+ | `COLLAB_AGENT_KIND` | Optional AI runtime for the dashboard icon (auto-detected) |
86
+ | `COLLAB_AGENT_MODE` | Set to `1` to auto-generate/persist an agent id when unset |
87
+ | `COLLAB_AGENT_HOOKS` | Set to `1` to enable the IDE edit hook that auto-claims agent edits |
88
+ | `COLLAB_WATCHER_AGENT_ID` | Opt in to a dedicated agent watcher (default: watcher = human) |
86
89
 
87
90
  > **Important:** `SUPABASE_SERVICE_ROLE_KEY` is needed for the dashboard's Force Release button. Without it, only your own locks can be released.
88
91
 
@@ -102,9 +105,28 @@ set COLLAB_AGENT_ID=agent-fix-tests
102
105
  collab acquire src/auth.py # conflict — locked by agent-refactor-auth
103
106
  ```
104
107
 
105
- For existing Supabase projects, re-run the `acquire_lock` function and add the `agent_id` /
106
- `agent_label` columns from the updated [supabase/schema.sql](supabase/schema.sql) (fresh installs
107
- already include them).
108
+ For existing Supabase projects, re-run [supabase/schema.sql](supabase/schema.sql) to add the
109
+ `agent_id` / `agent_label` / `origin` / `agent_kind` columns and the updated `acquire_lock` function
110
+ (the script is idempotent; fresh installs already include them).
111
+
112
+ #### Strict user-vs-agent attribution
113
+
114
+ The dashboard distinguishes **human** edits from **AI agent** edits and shows _what the agent is
115
+ working on_ — not a cryptic id. Attribution is decided by an explicit signal:
116
+
117
+ - The background watcher locks bulk git changes as the **human** (`User` chip), even inside an AI
118
+ IDE. So normal work is never mislabelled as an agent.
119
+ - An AI agent claims the files it edits, producing an **"AI Agent"** badge (runtime icon + task).
120
+ Make this automatic by wiring your IDE's edit hook to `collab claim` — see
121
+ [scripts/agent-hooks/](scripts/agent-hooks/README.md). It is **runtime-agnostic** (Cursor, Claude
122
+ Code, Copilot, Gemini, ...). Enable with `COLLAB_AGENT_HOOKS=1` and optionally
123
+ `COLLAB_AGENT_LABEL="<task>"`.
124
+
125
+ Agents can also claim explicitly:
126
+
127
+ ```bash
128
+ collab claim src/auth.py --label "refactor-auth" --reason "Refactor auth"
129
+ ```
108
130
 
109
131
  ### 4. Verify Setup
110
132
 
@@ -58,13 +58,19 @@ def detect_agent_runtime_label() -> Optional[str]:
58
58
 
59
59
 
60
60
  def is_agent_mode_requested() -> bool:
61
- """Return True when agent identity should be active for this process."""
61
+ """Return True when agent identity should be active for this process.
62
+
63
+ STRICT ATTRIBUTION: the mere *presence* of an AI runtime (e.g. a process
64
+ spawned from a Cursor/Claude terminal that exports ``CURSOR_TRACE_ID``) does
65
+ NOT by itself attribute locks to an agent. Doing so caused every background
66
+ auto-lock to be mislabelled as the runtime. Agent attribution now requires an
67
+ *explicit* signal: ``COLLAB_AGENT_ID`` or ``COLLAB_AGENT_MODE``. The detected
68
+ runtime is still used for friendly display only (see :func:`resolve_agent_kind`).
69
+ """
62
70
  if _read_clean_env("COLLAB_AGENT_ID"):
63
71
  return True
64
72
  if _is_truthy_env("COLLAB_AGENT_MODE"):
65
73
  return True
66
- if detect_agent_runtime_label():
67
- return True
68
74
  return False
69
75
 
70
76
 
@@ -152,12 +158,17 @@ def resolve_agent_label(
152
158
  explicit_label: Optional[str] = None,
153
159
  runtime_label: Optional[str] = None,
154
160
  ) -> Optional[str]:
155
- """Resolve optional human-readable agent label."""
161
+ """Resolve the human-readable *task* label (the "why / what for").
162
+
163
+ This intentionally does NOT fall back to the runtime name (e.g. ``cursor``): the
164
+ label describes the task an agent is working on (``fix-ci-dashboard``), while the
165
+ runtime family is tracked separately as ``agent_kind`` for display. When no task
166
+ label is supplied the dashboard shows a generic "AI Agent".
167
+ """
156
168
  for candidate in (
157
169
  explicit_label,
158
170
  _read_clean_env("COLLAB_AGENT_LABEL"),
159
171
  runtime_label,
160
- detect_agent_runtime_label(),
161
172
  ):
162
173
  if candidate:
163
174
  val = candidate.strip()
@@ -166,6 +177,40 @@ def resolve_agent_label(
166
177
  return None
167
178
 
168
179
 
180
+ def resolve_agent_kind(
181
+ *,
182
+ explicit_kind: Optional[str] = None,
183
+ agent_id: Optional[str] = None,
184
+ ) -> Optional[str]:
185
+ """Resolve the AI runtime family for friendly display (icon/name).
186
+
187
+ Precedence: explicit value → ``COLLAB_AGENT_KIND`` → detected runtime marker.
188
+ When an agent identity exists but the runtime is unknown, falls back to the
189
+ generic ``"other"`` so the dashboard can still render an AI badge. Returns
190
+ ``None`` for human (no agent) locks.
191
+ """
192
+ for candidate in (
193
+ explicit_kind,
194
+ _read_clean_env("COLLAB_AGENT_KIND"),
195
+ detect_agent_runtime_label(),
196
+ ):
197
+ if candidate:
198
+ val = candidate.strip().lower()
199
+ if val:
200
+ return val[:64]
201
+ if agent_id:
202
+ return "other"
203
+ return None
204
+
205
+
206
+ def resolve_origin(agent_id: Optional[str]) -> str:
207
+ """Return the authoritative attribution origin for a lock.
208
+
209
+ ``'agent'`` when a unique agent identity is present, otherwise ``'human'``.
210
+ """
211
+ return "agent" if agent_id else "human"
212
+
213
+
169
214
  def agent_ids_match(
170
215
  lock_agent_id: Optional[str],
171
216
  client_agent_id: Optional[str],
@@ -282,6 +327,7 @@ def identity_summary(
282
327
  developer_id: str,
283
328
  agent_id: Optional[str],
284
329
  agent_label: Optional[str],
330
+ agent_kind: Optional[str] = None,
285
331
  ) -> dict[str, Optional[str]]:
286
332
  """Return a dict suitable for ``collab whoami`` JSON output."""
287
333
  mode = "agent" if agent_id else "human"
@@ -289,5 +335,6 @@ def identity_summary(
289
335
  "developer_id": developer_id,
290
336
  "agent_id": agent_id,
291
337
  "agent_label": agent_label,
338
+ "agent_kind": agent_kind,
292
339
  "mode": mode,
293
340
  }
@@ -324,6 +324,55 @@
324
324
  color: #4338ca;
325
325
  }
326
326
 
327
+ /* Human vs AI actor attribution badges */
328
+ .actor-chip {
329
+ display: inline-flex;
330
+ align-items: center;
331
+ gap: 0.3rem;
332
+ margin-left: 0.4rem;
333
+ padding: 0.12rem 0.5rem;
334
+ border-radius: 999px;
335
+ font-size: 0.72rem;
336
+ font-weight: 600;
337
+ white-space: nowrap;
338
+ vertical-align: middle;
339
+ }
340
+
341
+ .user-chip {
342
+ background: #f1f5f9;
343
+ color: #475569;
344
+ border: 1px solid var(--border-color);
345
+ }
346
+
347
+ .agent-badge {
348
+ background: linear-gradient(135deg, #ede9fe 0%, #e0e7ff 100%);
349
+ color: #5b21b6;
350
+ border: 1px solid #ddd6fe;
351
+ }
352
+
353
+ .agent-badge-owner {
354
+ background: linear-gradient(135deg, #ddd6fe 0%, #c7d2fe 100%);
355
+ color: #4c1d95;
356
+ border-color: #c4b5fd;
357
+ }
358
+
359
+ .agent-badge .agent-badge-kind {
360
+ font-weight: 700;
361
+ text-transform: capitalize;
362
+ }
363
+
364
+ .agent-badge .agent-badge-task {
365
+ font-weight: 500;
366
+ opacity: 0.85;
367
+ max-width: 16rem;
368
+ overflow: hidden;
369
+ text-overflow: ellipsis;
370
+ }
371
+
372
+ .agent-badge .agent-badge-sep {
373
+ opacity: 0.5;
374
+ }
375
+
327
376
  code {
328
377
  color: #1d4ed8;
329
378
  background: #f8fafc;
@@ -759,20 +808,98 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
759
808
  return !!(SUPABASE_USER && lock.developer_id === SUPABASE_USER);
760
809
  }
761
810
 
811
+ function escapeHtml(value) {
812
+ return String(value == null ? "" : value)
813
+ .replace(/&/g, "&amp;")
814
+ .replace(/</g , "&lt;") .replace( />/g, "&gt;")
815
+ .replace(/"/g, "&quot;")
816
+ .replace(/'/g, "&#39;");
817
+ }
818
+
819
+ function isAgentLock(lock) {
820
+ if (lock.origin === "agent") {
821
+ return true;
822
+ }
823
+ // Backward compatibility for rows created before `origin` existed.
824
+ return !!lock.agent_id && lock.origin !== "human";
825
+ }
826
+
827
+ function agentKindIcon(kind) {
828
+ switch (String(kind || "").toLowerCase()) {
829
+ case "cursor":
830
+ return '<i class="fas fa-i-cursor"></i>';
831
+ case "copilot":
832
+ return '<i class="fab fa-github"></i>';
833
+ case "claude-code":
834
+ case "claude":
835
+ case "composer":
836
+ case "other":
837
+ default:
838
+ return '<i class="fas fa-robot"></i>';
839
+ }
840
+ }
841
+
842
+ function prettifyKind(kind) {
843
+ const k = String(kind || "").trim();
844
+ if (!k || k === "other") {
845
+ return "";
846
+ }
847
+ return k
848
+ .split(/[-_]/)
849
+ .map((p) => (p ? p.charAt(0).toUpperCase() + p.slice(1) : p))
850
+ .join(" ");
851
+ }
852
+
762
853
  function formatDeveloperCell(lock) {
763
854
  const dev = lock.developer_id || "?";
764
- const agentText = lock.agent_label || lock.agent_id;
765
- const chip = agentText
766
- ? '<span class="agent-chip' + (lockOwnedByMe(lock) ? " agent-chip-owner" : "") + '">' +
767
- agentText +
768
- "</span>"
769
- : '<span class="agent-chip">user</span>';
770
- return (
855
+ const devTag =
771
856
  '<span class="dev-tag ' + (lockOwnedByMe(lock) ? "dev-tag-owner" : "") + '">' +
772
- dev +
773
- "</span>" +
774
- chip
775
- );
857
+ escapeHtml(dev) +
858
+ "</span>";
859
+
860
+ if (!isAgentLock(lock)) {
861
+ const userChip =
862
+ '<span class="actor-chip user-chip" title="Edited by a human developer">' +
863
+ '<i class="fas fa-user"></i>User</span>';
864
+ return devTag + userChip;
865
+ }
866
+
867
+ // AI agent lock: show clearly that it was an AI agent and what for.
868
+ // The raw agent_id is intentionally never displayed in the cell — it
869
+ // appears only in the hover tooltip for debugging.
870
+ const kind = lock.agent_kind || "";
871
+ const kindName = prettifyKind(kind);
872
+ const task = String(lock.agent_label || "").trim();
873
+
874
+ const tip = ["Edited by an AI agent"];
875
+ if (kindName) tip.push("Runtime: " + kindName);
876
+ if (task) tip.push("Task: " + task);
877
+ if (lock.agent_id) tip.push("Agent id: " + lock.agent_id);
878
+ if (lock.reason) tip.push("Reason: " + lock.reason);
879
+
880
+ let inner =
881
+ agentKindIcon(kind) + '<span class="agent-badge-text">AI Agent</span>';
882
+ if (kindName) {
883
+ inner +=
884
+ '<span class="agent-badge-sep">·</span>' +
885
+ '<span class="agent-badge-kind">' +
886
+ escapeHtml(kindName) +
887
+ "</span>";
888
+ }
889
+ if (task) {
890
+ inner +=
891
+ '<span class="agent-badge-sep">·</span>' +
892
+ '<span class="agent-badge-task">' +
893
+ escapeHtml(task) +
894
+ "</span>";
895
+ }
896
+
897
+ const badge =
898
+ '<span class="actor-chip agent-badge' + (lockOwnedByMe(lock) ? " agent-badge-owner" : "") + '" title="' + escapeHtml(tip.join("\n")) + '">' +
899
+ inner +
900
+ "</span>";
901
+
902
+ return devTag + badge;
776
903
  }
777
904
 
778
905
  function updateTimestamp() {
@@ -161,6 +161,7 @@ def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
161
161
  state_dir = state_override or os.path.join(project_root, ".collab")
162
162
  agent_id = agent_identity.resolve_agent_id(state_dir)
163
163
  agent_label = agent_identity.resolve_agent_label()
164
+ agent_kind = agent_identity.resolve_agent_kind(agent_id=agent_id)
164
165
  project_name = resolve_project_display_name(project_root, file_vals)
165
166
  return {
166
167
  "url": url,
@@ -169,6 +170,7 @@ def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
169
170
  "user": user,
170
171
  "agentId": agent_id,
171
172
  "agentLabel": agent_label,
173
+ "agentKind": agent_kind,
172
174
  "projectName": project_name,
173
175
  }
174
176