dryft 0.1.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.
- dryft-0.1.1/PKG-INFO +35 -0
- dryft-0.1.1/README.md +17 -0
- dryft-0.1.1/pyproject.toml +32 -0
- dryft-0.1.1/setup.cfg +4 -0
- dryft-0.1.1/src/dryft/__init__.py +3 -0
- dryft-0.1.1/src/dryft/api.py +73 -0
- dryft-0.1.1/src/dryft/cli.py +201 -0
- dryft-0.1.1/src/dryft/config.py +55 -0
- dryft-0.1.1/src/dryft/context.py +176 -0
- dryft-0.1.1/src/dryft/daemon.py +215 -0
- dryft-0.1.1/src/dryft/git.py +87 -0
- dryft-0.1.1/src/dryft/init_.py +141 -0
- dryft-0.1.1/src/dryft/intent.py +62 -0
- dryft-0.1.1/src/dryft/templates/CLAUDE.md.dryft +30 -0
- dryft-0.1.1/src/dryft/templates/cursorrules.dryft +30 -0
- dryft-0.1.1/src/dryft.egg-info/PKG-INFO +35 -0
- dryft-0.1.1/src/dryft.egg-info/SOURCES.txt +19 -0
- dryft-0.1.1/src/dryft.egg-info/dependency_links.txt +1 -0
- dryft-0.1.1/src/dryft.egg-info/entry_points.txt +2 -0
- dryft-0.1.1/src/dryft.egg-info/requires.txt +1 -0
- dryft-0.1.1/src/dryft.egg-info/top_level.txt +1 -0
dryft-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dryft
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Dryft CLI and daemon — sync agent context with Dryft server
|
|
5
|
+
Author: Dryft
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dryft,ai,agents,coordination,git
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: certifi
|
|
18
|
+
|
|
19
|
+
# Dryft Python client
|
|
20
|
+
|
|
21
|
+
Same CLI as the Node client, installable via **pip** per the PRD:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install dryft
|
|
25
|
+
dryft init
|
|
26
|
+
dryft start
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From source (repo root):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e ./client-py
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Commands: `init`, `start`, `stop`, `status`, `config`, `push-notify`. Implementation follows the same phases as the Node client in `client/`.
|
dryft-0.1.1/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Dryft Python client
|
|
2
|
+
|
|
3
|
+
Same CLI as the Node client, installable via **pip** per the PRD:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install dryft
|
|
7
|
+
dryft init
|
|
8
|
+
dryft start
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
From source (repo root):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ./client-py
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Commands: `init`, `start`, `stop`, `status`, `config`, `push-notify`. Implementation follows the same phases as the Node client in `client/`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dryft"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Dryft CLI and daemon — sync agent context with Dryft server"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Dryft" }]
|
|
13
|
+
dependencies = ["certifi"]
|
|
14
|
+
keywords = ["dryft", "ai", "agents", "coordination", "git"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
dryft = "dryft.cli:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
dryft = ["templates/*.dryft"]
|
dryft-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for Dryft server: POST /context, GET /context/:projectId, POST /push-notify.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import ssl
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import certifi
|
|
13
|
+
_ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
14
|
+
except ImportError:
|
|
15
|
+
_ssl_context = ssl.create_default_context()
|
|
16
|
+
# macOS Python often lacks system certs; fall back to unverified if needed
|
|
17
|
+
try:
|
|
18
|
+
urllib.request.urlopen("https://example.com", context=_ssl_context, timeout=5)
|
|
19
|
+
except ssl.SSLCertVerificationError:
|
|
20
|
+
_ssl_context = ssl.create_unverified_context()
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _base(base_url: str, path: str) -> str:
|
|
26
|
+
base_url = base_url.rstrip("/")
|
|
27
|
+
path = path if path.startswith("/") else f"/{path}"
|
|
28
|
+
return f"{base_url}{path}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def post_context(base_url: str, payload: dict) -> None:
|
|
32
|
+
url = _base(base_url, "/context")
|
|
33
|
+
data = json.dumps(payload).encode("utf-8")
|
|
34
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
35
|
+
req.add_header("Content-Type", "application/json")
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(req, context=_ssl_context) as res:
|
|
38
|
+
if res.status >= 400:
|
|
39
|
+
raise RuntimeError(f"POST /context failed: {res.status} {res.read().decode()}")
|
|
40
|
+
except urllib.error.HTTPError as e:
|
|
41
|
+
body = e.read().decode() if e.fp else ""
|
|
42
|
+
raise RuntimeError(f"POST /context failed: {e.code} {body}") from e
|
|
43
|
+
except urllib.error.URLError as e:
|
|
44
|
+
raise RuntimeError(f"POST /context failed: {e.reason}") from e
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_context(base_url: str, project_id: str) -> dict:
|
|
48
|
+
url = _base(base_url, f"/context/{quote(project_id, safe='')}")
|
|
49
|
+
req = urllib.request.Request(url, method="GET")
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req, context=_ssl_context) as res:
|
|
52
|
+
return json.loads(res.read().decode())
|
|
53
|
+
except urllib.error.HTTPError as e:
|
|
54
|
+
body = e.read().decode() if e.fp else ""
|
|
55
|
+
raise RuntimeError(f"GET /context failed: {e.code} {body}") from e
|
|
56
|
+
except urllib.error.URLError as e:
|
|
57
|
+
raise RuntimeError(f"GET /context failed: {e.reason}") from e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def post_push_notify(base_url: str, body: dict) -> None:
|
|
61
|
+
url = _base(base_url, "/push-notify")
|
|
62
|
+
data = json.dumps(body).encode("utf-8")
|
|
63
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
64
|
+
req.add_header("Content-Type", "application/json")
|
|
65
|
+
try:
|
|
66
|
+
with urllib.request.urlopen(req, context=_ssl_context) as res:
|
|
67
|
+
if res.status >= 400:
|
|
68
|
+
raise RuntimeError(f"POST /push-notify failed: {res.status} {res.read().decode()}")
|
|
69
|
+
except urllib.error.HTTPError as e:
|
|
70
|
+
body_text = e.read().decode() if e.fp else ""
|
|
71
|
+
raise RuntimeError(f"POST /push-notify failed: {e.code} {body_text}") from e
|
|
72
|
+
except urllib.error.URLError as e:
|
|
73
|
+
raise RuntimeError(f"POST /push-notify failed: {e.reason}") from e
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dryft CLI entry. Commands: init, start, stop, status, config, push-notify.
|
|
3
|
+
Matches PRD §6.1 and §12.1.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import api, config as config_mod
|
|
13
|
+
from .init_ import run_init
|
|
14
|
+
from . import daemon as daemon_mod
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cwd(args: argparse.Namespace) -> str:
|
|
18
|
+
return getattr(args, "cwd", None) or os.getcwd()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
22
|
+
"""Create .dryft/, context.md, intent.md, .gitignore; prompt for server URL and project id."""
|
|
23
|
+
try:
|
|
24
|
+
run_init(_cwd(args))
|
|
25
|
+
return 0
|
|
26
|
+
except SystemExit as e:
|
|
27
|
+
print(e, file=sys.stderr)
|
|
28
|
+
return 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_start(args: argparse.Namespace) -> int:
|
|
32
|
+
"""Start the background daemon (polling + sync). Use --foreground to run in terminal."""
|
|
33
|
+
try:
|
|
34
|
+
daemon_mod.start_daemon(
|
|
35
|
+
cwd=_cwd(args),
|
|
36
|
+
foreground=getattr(args, "foreground", False),
|
|
37
|
+
)
|
|
38
|
+
return 0
|
|
39
|
+
except SystemExit as e:
|
|
40
|
+
print(e, file=sys.stderr)
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_stop(args: argparse.Namespace) -> int:
|
|
45
|
+
"""Stop the daemon."""
|
|
46
|
+
daemon_mod.stop_daemon(_cwd(args))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
51
|
+
"""Show current sync status and any warnings."""
|
|
52
|
+
cwd = _cwd(args)
|
|
53
|
+
cfg = config_mod.load_config(cwd)
|
|
54
|
+
if not cfg:
|
|
55
|
+
print("Run dryft init first.", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
last_sync_path = Path(cwd) / ".dryft" / "last_sync.json"
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(last_sync_path.read_text(encoding="utf-8"))
|
|
60
|
+
if data.get("last_sync"):
|
|
61
|
+
print("Last sync:", data["last_sync"])
|
|
62
|
+
if data.get("error"):
|
|
63
|
+
print("Last error:", data["error"])
|
|
64
|
+
except (OSError, json.JSONDecodeError):
|
|
65
|
+
print("No sync recorded yet. Run dryft start to sync.")
|
|
66
|
+
try:
|
|
67
|
+
state = api.get_context(cfg["server_url"], cfg["project_id"])
|
|
68
|
+
devs = state.get("developers") or {}
|
|
69
|
+
others = [k for k in devs if k != cfg["developer_id"]]
|
|
70
|
+
if devs:
|
|
71
|
+
print("Other developers:", ", ".join(others) or "none")
|
|
72
|
+
warnings = state.get("warnings") or []
|
|
73
|
+
if warnings:
|
|
74
|
+
print("Warnings:", len(warnings))
|
|
75
|
+
for w in warnings:
|
|
76
|
+
print(" -", w.get("detail") or f"{w.get('file', '?')}: {', '.join(w.get('developers') or [])}")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print("Could not fetch server state:", e)
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
83
|
+
"""View or edit configuration (server URL, developer id, poll interval)."""
|
|
84
|
+
cwd = _cwd(args)
|
|
85
|
+
cfg = config_mod.load_config(cwd)
|
|
86
|
+
if not cfg:
|
|
87
|
+
print("Run dryft init first.", file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
sub = getattr(args, "config_args", []) or [] # e.g. ["set", "server_url", "http://..."]
|
|
90
|
+
if len(sub) >= 3 and sub[0] == "set" and sub[1] and sub[2] is not None:
|
|
91
|
+
key, value = sub[1], sub[2]
|
|
92
|
+
next_cfg = dict(cfg)
|
|
93
|
+
if key == "server_url":
|
|
94
|
+
next_cfg["server_url"] = value.rstrip("/")
|
|
95
|
+
elif key == "project_id":
|
|
96
|
+
next_cfg["project_id"] = value
|
|
97
|
+
elif key == "developer_id":
|
|
98
|
+
next_cfg["developer_id"] = value
|
|
99
|
+
elif key == "poll_interval_ms":
|
|
100
|
+
next_cfg["poll_interval_ms"] = int(value) or cfg.get("poll_interval_ms") or 60_000
|
|
101
|
+
else:
|
|
102
|
+
print("Unknown key. Use: server_url, project_id, developer_id, poll_interval_ms", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
config_mod.save_config(cwd, next_cfg)
|
|
105
|
+
print("Config updated.")
|
|
106
|
+
return 0
|
|
107
|
+
print("server_url:", cfg["server_url"])
|
|
108
|
+
print("project_id:", cfg["project_id"])
|
|
109
|
+
print("developer_id:", cfg["developer_id"])
|
|
110
|
+
print("poll_interval_ms:", cfg.get("poll_interval_ms") or 60_000)
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_push_notify(args: argparse.Namespace) -> int:
|
|
115
|
+
"""Manually notify the other developer that you've pushed."""
|
|
116
|
+
cwd = _cwd(args)
|
|
117
|
+
cfg = config_mod.load_config(cwd)
|
|
118
|
+
if not cfg:
|
|
119
|
+
print("Run dryft init first.", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
try:
|
|
122
|
+
api.post_push_notify(
|
|
123
|
+
cfg["server_url"],
|
|
124
|
+
{"developer": cfg["developer_id"], "project_id": cfg["project_id"]},
|
|
125
|
+
)
|
|
126
|
+
print("Push notification sent.")
|
|
127
|
+
return 0
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print("Push notify failed:", e, file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_update(_args: argparse.Namespace) -> int:
|
|
134
|
+
"""Upgrade Dryft client to the latest version (pip install --upgrade dryft)."""
|
|
135
|
+
import subprocess
|
|
136
|
+
print("Upgrading Dryft client to latest version...")
|
|
137
|
+
try:
|
|
138
|
+
code = subprocess.run(
|
|
139
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "dryft"],
|
|
140
|
+
check=False,
|
|
141
|
+
).returncode
|
|
142
|
+
if code != 0:
|
|
143
|
+
print("Update failed. You can try manually: pip install --upgrade dryft", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
print("Dryft updated. Run 'dryft --help' to see commands.")
|
|
146
|
+
return 0
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
print("Could not find pip. Try: pip install --upgrade dryft", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_help(_args: argparse.Namespace) -> int:
|
|
153
|
+
"""Print usage."""
|
|
154
|
+
help_text = """Dryft CLI (Stage 1)
|
|
155
|
+
Usage: dryft <command>
|
|
156
|
+
|
|
157
|
+
Commands:
|
|
158
|
+
init Initialize Dryft in the current repo
|
|
159
|
+
start Start the background daemon
|
|
160
|
+
stop Stop the daemon
|
|
161
|
+
status Show sync status and warnings
|
|
162
|
+
config View or edit config (server URL, developer id, poll interval)
|
|
163
|
+
push-notify Notify the other developer that you've pushed
|
|
164
|
+
update Upgrade Dryft client to the latest version (pip install --upgrade dryft)
|
|
165
|
+
|
|
166
|
+
Install: pip install dryft (or npm install -g dryft for Node client)
|
|
167
|
+
See REPO_STRUCTURE.md and the PRD for full details.
|
|
168
|
+
"""
|
|
169
|
+
print(help_text)
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main() -> int:
|
|
174
|
+
parser = argparse.ArgumentParser(prog="dryft", description="Dryft — agent context sync")
|
|
175
|
+
parser.add_argument("--cwd", default=None, help="Working directory (default: current)")
|
|
176
|
+
subparsers = parser.add_subparsers(dest="command", help="Command")
|
|
177
|
+
|
|
178
|
+
for name, fn in [
|
|
179
|
+
("init", cmd_init),
|
|
180
|
+
("start", cmd_start),
|
|
181
|
+
("stop", cmd_stop),
|
|
182
|
+
("status", cmd_status),
|
|
183
|
+
("config", cmd_config),
|
|
184
|
+
("push-notify", cmd_push_notify),
|
|
185
|
+
("update", cmd_update),
|
|
186
|
+
]:
|
|
187
|
+
sub = subparsers.add_parser(name)
|
|
188
|
+
sub.set_defaults(func=fn)
|
|
189
|
+
if name == "start":
|
|
190
|
+
sub.add_argument("--foreground", action="store_true", help="Run daemon in foreground")
|
|
191
|
+
elif name == "config":
|
|
192
|
+
sub.add_argument("config_args", nargs="*", help="get | set key value")
|
|
193
|
+
|
|
194
|
+
args = parser.parse_args()
|
|
195
|
+
if getattr(args, "func", None) is None:
|
|
196
|
+
return print_help(args)
|
|
197
|
+
return args.func(args)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
sys.exit(main())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Load/save .dryft/config.json and ensure .dryft directory exists.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# PRD §8.3: default 5 seconds for near-real-time awareness
|
|
9
|
+
DEFAULT_POLL_INTERVAL_MS = 5_000
|
|
10
|
+
CONFIG_FILENAME = "config.json"
|
|
11
|
+
DRYFT_DIR = ".dryft"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _config_path(cwd: str) -> Path:
|
|
15
|
+
return Path(cwd) / DRYFT_DIR / CONFIG_FILENAME
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ensure_dryft_dir(cwd: str) -> str:
|
|
19
|
+
dir_path = Path(cwd) / DRYFT_DIR
|
|
20
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return str(dir_path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config(cwd: str) -> dict | None:
|
|
25
|
+
path = _config_path(cwd)
|
|
26
|
+
try:
|
|
27
|
+
raw = path.read_text(encoding="utf-8")
|
|
28
|
+
data = json.loads(raw)
|
|
29
|
+
if not data or not isinstance(data, dict):
|
|
30
|
+
return None
|
|
31
|
+
if not all(k in data and isinstance(data.get(k), str) for k in ("server_url", "project_id", "developer_id")):
|
|
32
|
+
return None
|
|
33
|
+
poll = data.get("poll_interval_ms")
|
|
34
|
+
if not isinstance(poll, (int, float)) or poll <= 0:
|
|
35
|
+
poll = DEFAULT_POLL_INTERVAL_MS
|
|
36
|
+
return {
|
|
37
|
+
"server_url": (data["server_url"] or "").rstrip("/"),
|
|
38
|
+
"project_id": data["project_id"],
|
|
39
|
+
"developer_id": data["developer_id"],
|
|
40
|
+
"poll_interval_ms": int(poll),
|
|
41
|
+
}
|
|
42
|
+
except (OSError, json.JSONDecodeError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_config(cwd: str, config: dict) -> None:
|
|
47
|
+
ensure_dryft_dir(cwd)
|
|
48
|
+
path = _config_path(cwd)
|
|
49
|
+
data = {
|
|
50
|
+
"server_url": config["server_url"],
|
|
51
|
+
"project_id": config["project_id"],
|
|
52
|
+
"developer_id": config["developer_id"],
|
|
53
|
+
"poll_interval_ms": config.get("poll_interval_ms") or DEFAULT_POLL_INTERVAL_MS,
|
|
54
|
+
}
|
|
55
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build context payload for POST and render/write .dryft/context.md from GET response.
|
|
3
|
+
PRD §7.1: template with Last synced, Project, Last Activity, Guidelines, severity; §7.4: ~200 line cap.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
CONTEXT_LINE_CAP = 200 # PRD §7.4, §17.4
|
|
11
|
+
|
|
12
|
+
GUIDELINES_BLOCK = """
|
|
13
|
+
## Guidelines for AI Agent
|
|
14
|
+
|
|
15
|
+
### Before Starting Any Task
|
|
16
|
+
- Read this entire file.
|
|
17
|
+
- Check if any files you plan to modify are listed under "Currently Active Changes" or "Files They Plan to Modify Next."
|
|
18
|
+
- If overlap exists, inform your developer immediately before proceeding.
|
|
19
|
+
|
|
20
|
+
### If You Share a File With the Other Agent
|
|
21
|
+
- Read "What they changed" to understand exactly which functions/sections they are modifying.
|
|
22
|
+
- Work in DIFFERENT functions or sections of the same file where possible.
|
|
23
|
+
- If you must modify the SAME function they are working on, STOP and inform your developer — this is a high-conflict risk.
|
|
24
|
+
- Do not change function signatures, interfaces, or exports that the other agent's code depends on.
|
|
25
|
+
|
|
26
|
+
### During Work
|
|
27
|
+
- If this file updates with a new warning, pause and inform your developer.
|
|
28
|
+
- If the other developer completes changes to files you depend on, suggest pulling latest.
|
|
29
|
+
- Keep your .dryft/intent.md updated after every file modification so the other agent can see your progress.
|
|
30
|
+
""".strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _time_ago(iso: str) -> str:
|
|
34
|
+
try:
|
|
35
|
+
then = datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
|
|
36
|
+
sec = int(datetime.now(timezone.utc).timestamp() - then)
|
|
37
|
+
if sec < 60:
|
|
38
|
+
return "just now"
|
|
39
|
+
if sec < 3600:
|
|
40
|
+
return f"{sec // 60} min ago"
|
|
41
|
+
if sec < 86400:
|
|
42
|
+
return f"{sec // 3600} h ago"
|
|
43
|
+
return f"{sec // 86400} d ago"
|
|
44
|
+
except (ValueError, TypeError):
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_payload(
|
|
49
|
+
developer: str,
|
|
50
|
+
git_changes: list[dict],
|
|
51
|
+
intent: dict | None,
|
|
52
|
+
project_id: str | None = None,
|
|
53
|
+
) -> dict:
|
|
54
|
+
active_changes = [
|
|
55
|
+
{
|
|
56
|
+
"file": c["file"],
|
|
57
|
+
"functions_modified": c.get("functionsModified", []),
|
|
58
|
+
"change_type": c.get("changeType", "modification"),
|
|
59
|
+
}
|
|
60
|
+
for c in git_changes
|
|
61
|
+
]
|
|
62
|
+
payload: dict[str, Any] = {
|
|
63
|
+
"developer": developer,
|
|
64
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
65
|
+
"active_changes": active_changes,
|
|
66
|
+
}
|
|
67
|
+
if intent and intent.get("dependencies"):
|
|
68
|
+
payload["dependencies_affected"] = intent["dependencies"]
|
|
69
|
+
if intent:
|
|
70
|
+
payload["intent"] = {}
|
|
71
|
+
if intent.get("plannedFiles"):
|
|
72
|
+
payload["intent"]["planned_files"] = intent["plannedFiles"]
|
|
73
|
+
if intent.get("currentTask"):
|
|
74
|
+
payload["intent"]["current_task"] = intent["currentTask"]
|
|
75
|
+
if intent.get("dependencies"):
|
|
76
|
+
payload["intent"]["dependencies"] = intent["dependencies"]
|
|
77
|
+
if project_id:
|
|
78
|
+
payload["project_id"] = project_id
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_context_md(server_state: dict, my_developer_id: str) -> str:
|
|
83
|
+
developers = server_state.get("developers") or {}
|
|
84
|
+
warnings = server_state.get("warnings") or []
|
|
85
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
86
|
+
project_id = server_state.get("project_id") or ""
|
|
87
|
+
|
|
88
|
+
lines = [
|
|
89
|
+
"# Dryft — Shared Development Context",
|
|
90
|
+
"",
|
|
91
|
+
f"> Last synced: {now}",
|
|
92
|
+
]
|
|
93
|
+
if project_id:
|
|
94
|
+
lines.append(f"> Project: {project_id}")
|
|
95
|
+
lines.append("")
|
|
96
|
+
|
|
97
|
+
others = [(k, v) for k, v in developers.items() if k != my_developer_id]
|
|
98
|
+
if not others:
|
|
99
|
+
lines.append("> No other developers in context yet.")
|
|
100
|
+
else:
|
|
101
|
+
for dev_id, dev in others:
|
|
102
|
+
lines.append(f"## Other Developer Activity ({dev_id})")
|
|
103
|
+
lines.append("")
|
|
104
|
+
if dev.get("last_update"):
|
|
105
|
+
ago = _time_ago(dev["last_update"])
|
|
106
|
+
if ago:
|
|
107
|
+
lines.append("### Last Activity")
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.append(f"{dev_id} last active — {ago}")
|
|
110
|
+
lines.append("")
|
|
111
|
+
planned = dev.get("intent") or {}
|
|
112
|
+
if planned.get("current_task"):
|
|
113
|
+
lines.append("### Current Task")
|
|
114
|
+
lines.append("")
|
|
115
|
+
lines.append(str(planned["current_task"]))
|
|
116
|
+
lines.append("")
|
|
117
|
+
changes = dev.get("active_changes") or []
|
|
118
|
+
if changes:
|
|
119
|
+
lines.append("### Currently Active Changes")
|
|
120
|
+
lines.append("")
|
|
121
|
+
for c in changes:
|
|
122
|
+
funcs = c.get("functions_modified") or []
|
|
123
|
+
func_str = f" — {', '.join(funcs)}" if funcs else ""
|
|
124
|
+
lines.append(f"- **{c.get('file', '?')}** — {c.get('change_type', 'change')}{func_str}")
|
|
125
|
+
if c.get("summary"):
|
|
126
|
+
lines.append(f" - Why: {c['summary']}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
planned_files = planned.get("planned_files") or []
|
|
129
|
+
if planned_files:
|
|
130
|
+
lines.append("### Files They Plan to Modify Next")
|
|
131
|
+
lines.append("")
|
|
132
|
+
for f in planned_files:
|
|
133
|
+
lines.append(f"- {f}")
|
|
134
|
+
lines.append("")
|
|
135
|
+
deps = dev.get("dependencies_affected") or []
|
|
136
|
+
if deps:
|
|
137
|
+
lines.append("### Dependencies Affected")
|
|
138
|
+
lines.append("")
|
|
139
|
+
for d in deps:
|
|
140
|
+
lines.append(f"- {d}")
|
|
141
|
+
lines.append("")
|
|
142
|
+
if warnings:
|
|
143
|
+
lines.append("---")
|
|
144
|
+
lines.append("")
|
|
145
|
+
lines.append("## Warnings")
|
|
146
|
+
lines.append("")
|
|
147
|
+
for w in warnings:
|
|
148
|
+
severity = "WARNING" if w.get("type") == "overlap" else "INFO"
|
|
149
|
+
detail = w.get("detail") or ", ".join(w.get("developers") or [])
|
|
150
|
+
lines.append(f"- **{severity}** — {w.get('file', '?')}: {detail}")
|
|
151
|
+
lines.append("")
|
|
152
|
+
last_push = server_state.get("last_push")
|
|
153
|
+
if last_push and last_push.get("at"):
|
|
154
|
+
lines.append("---")
|
|
155
|
+
lines.append("")
|
|
156
|
+
dev = last_push.get("developer")
|
|
157
|
+
lines.append(f"**Last push:** {last_push['at']}" + (f" by {dev}" if dev else ""))
|
|
158
|
+
lines.append("")
|
|
159
|
+
lines.append("---")
|
|
160
|
+
lines.append("")
|
|
161
|
+
lines.append(GUIDELINES_BLOCK)
|
|
162
|
+
|
|
163
|
+
out = "\n".join(lines)
|
|
164
|
+
line_list = out.split("\n")
|
|
165
|
+
if len(line_list) > CONTEXT_LINE_CAP:
|
|
166
|
+
kept = line_list[: CONTEXT_LINE_CAP - 2]
|
|
167
|
+
kept.append("")
|
|
168
|
+
kept.append("... (context truncated to 200 lines)")
|
|
169
|
+
return "\n".join(kept)
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_context_file(cwd: str, content: str) -> None:
|
|
174
|
+
path = Path(cwd) / ".dryft" / "context.md"
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
path.write_text(content, encoding="utf-8")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background daemon: polling loop and sync cycle.
|
|
3
|
+
Every N seconds: git detection + intent read → POST context → GET state → write context.md.
|
|
4
|
+
PRD §6.2/§6.3: terminal notifications for overlap warnings and push.
|
|
5
|
+
Phase 4: PID file for daemon lifecycle; stop kills the background process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .api import get_context, post_context
|
|
18
|
+
from .config import load_config
|
|
19
|
+
from .context import build_payload, render_context_md, write_context_file
|
|
20
|
+
from .git import detect_changes
|
|
21
|
+
from .intent import read_intent
|
|
22
|
+
|
|
23
|
+
LAST_SYNC_FILENAME = Path(".dryft") / "last_sync.json"
|
|
24
|
+
DAEMON_PID_FILENAME = Path(".dryft") / "daemon.pid"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_last_sync(cwd: str) -> dict:
|
|
28
|
+
path = Path(cwd) / LAST_SYNC_FILENAME
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
31
|
+
return data if isinstance(data, dict) else {}
|
|
32
|
+
except (OSError, json.JSONDecodeError):
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run_sync(cwd: str) -> None:
|
|
37
|
+
config = load_config(cwd)
|
|
38
|
+
if not config:
|
|
39
|
+
return
|
|
40
|
+
previous = _read_last_sync(cwd)
|
|
41
|
+
git_changes = detect_changes(cwd)
|
|
42
|
+
intent = read_intent(cwd)
|
|
43
|
+
payload = build_payload(
|
|
44
|
+
config["developer_id"],
|
|
45
|
+
git_changes,
|
|
46
|
+
intent,
|
|
47
|
+
config.get("project_id"),
|
|
48
|
+
)
|
|
49
|
+
post_context(config["server_url"], payload)
|
|
50
|
+
state = get_context(config["server_url"], config["project_id"])
|
|
51
|
+
markdown = render_context_md(state, config["developer_id"])
|
|
52
|
+
write_context_file(cwd, markdown)
|
|
53
|
+
|
|
54
|
+
# PRD §6.3: terminal notification when overlap detected
|
|
55
|
+
warnings = state.get("warnings") or []
|
|
56
|
+
for w in warnings:
|
|
57
|
+
f = w.get("file", "?")
|
|
58
|
+
detail = w.get("detail", "")
|
|
59
|
+
print(f"⚠ Overlap detected on {f}: {detail}", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
# PRD §6.2 Step 9 / §10.3: terminal notification when other dev pushed
|
|
62
|
+
last_push: dict[str, Any] = state.get("last_push") or {}
|
|
63
|
+
if last_push.get("at") and last_push.get("developer") != config["developer_id"]:
|
|
64
|
+
seen = previous.get("last_push_seen") or {}
|
|
65
|
+
is_new = seen.get("at") != last_push.get("at") or seen.get("developer") != last_push.get("developer")
|
|
66
|
+
if is_new:
|
|
67
|
+
print(f"{last_push.get('developer')} pushed changes. Consider pulling before continuing.", file=sys.stderr)
|
|
68
|
+
|
|
69
|
+
next_data: dict[str, Any] = {
|
|
70
|
+
"last_sync": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
71
|
+
"error": None,
|
|
72
|
+
}
|
|
73
|
+
if last_push.get("at"):
|
|
74
|
+
next_data["last_push_seen"] = {"at": last_push["at"], "developer": last_push.get("developer")}
|
|
75
|
+
else:
|
|
76
|
+
next_data["last_push_seen"] = previous.get("last_push_seen")
|
|
77
|
+
|
|
78
|
+
last_sync_path = Path(cwd) / LAST_SYNC_FILENAME
|
|
79
|
+
last_sync_path.write_text(json.dumps(next_data, indent=2), encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_sync_and_log(cwd: str) -> None:
|
|
83
|
+
last_sync_path = Path(cwd) / LAST_SYNC_FILENAME
|
|
84
|
+
try:
|
|
85
|
+
_run_sync(cwd)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
msg = str(e)
|
|
88
|
+
try:
|
|
89
|
+
last_sync_path.write_text(
|
|
90
|
+
json.dumps(
|
|
91
|
+
{"last_sync": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "error": msg},
|
|
92
|
+
indent=2,
|
|
93
|
+
),
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
)
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
98
|
+
print("Dryft sync error:", msg, file=sys.stderr)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _pid_path(cwd: str) -> Path:
|
|
102
|
+
return Path(cwd) / DAEMON_PID_FILENAME
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _remove_pid_file(cwd: str) -> None:
|
|
106
|
+
try:
|
|
107
|
+
_pid_path(cwd).unlink()
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_daemon_loop(
|
|
113
|
+
*,
|
|
114
|
+
cwd: str | None = None,
|
|
115
|
+
poll_interval_ms: int | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Run the sync loop in the current process (used by --foreground)."""
|
|
118
|
+
import time
|
|
119
|
+
|
|
120
|
+
cwd = cwd or str(Path.cwd())
|
|
121
|
+
if isinstance(cwd, Path):
|
|
122
|
+
cwd = str(cwd)
|
|
123
|
+
config = load_config(cwd)
|
|
124
|
+
if not config:
|
|
125
|
+
raise SystemExit("Run dryft init first.")
|
|
126
|
+
interval_ms = poll_interval_ms or config.get("poll_interval_ms") or 5_000
|
|
127
|
+
interval_sec = interval_ms / 1000.0
|
|
128
|
+
|
|
129
|
+
def shutdown(*_args: object) -> None:
|
|
130
|
+
_remove_pid_file(cwd)
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
|
|
133
|
+
signal.signal(signal.SIGINT, shutdown)
|
|
134
|
+
signal.signal(signal.SIGTERM, shutdown)
|
|
135
|
+
|
|
136
|
+
_run_sync_and_log(cwd)
|
|
137
|
+
print(f"Dryft daemon running. Syncing every {interval_sec:.0f} s. Ctrl+C to stop.")
|
|
138
|
+
try:
|
|
139
|
+
while True:
|
|
140
|
+
time.sleep(interval_sec)
|
|
141
|
+
_run_sync_and_log(cwd)
|
|
142
|
+
except KeyboardInterrupt:
|
|
143
|
+
shutdown()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def start_daemon(
|
|
147
|
+
*,
|
|
148
|
+
cwd: str | None = None,
|
|
149
|
+
poll_interval_ms: int | None = None,
|
|
150
|
+
foreground: bool = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
cwd = cwd or str(Path.cwd())
|
|
153
|
+
if isinstance(cwd, Path):
|
|
154
|
+
cwd = str(cwd)
|
|
155
|
+
|
|
156
|
+
if foreground:
|
|
157
|
+
run_daemon_loop(cwd=cwd, poll_interval_ms=poll_interval_ms)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
config = load_config(cwd)
|
|
161
|
+
if not config:
|
|
162
|
+
raise SystemExit("Run dryft init first.")
|
|
163
|
+
|
|
164
|
+
pid_path = _pid_path(cwd)
|
|
165
|
+
if pid_path.exists():
|
|
166
|
+
try:
|
|
167
|
+
existing_pid = int(pid_path.read_text().strip())
|
|
168
|
+
os.kill(existing_pid, 0)
|
|
169
|
+
print("Daemon already running (PID", existing_pid, "). Use 'dryft stop' first.", file=sys.stderr)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
except (ValueError, ProcessLookupError, OSError):
|
|
172
|
+
pid_path.unlink(missing_ok=True)
|
|
173
|
+
|
|
174
|
+
proc = subprocess.Popen(
|
|
175
|
+
[sys.executable, "-m", "dryft.cli", "start", "--foreground"],
|
|
176
|
+
cwd=cwd,
|
|
177
|
+
start_new_session=True,
|
|
178
|
+
stdout=subprocess.DEVNULL,
|
|
179
|
+
stderr=subprocess.DEVNULL,
|
|
180
|
+
)
|
|
181
|
+
if proc.pid is not None:
|
|
182
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
|
184
|
+
print("Dryft daemon started in background (PID", proc.pid, "). Use 'dryft stop' to stop.")
|
|
185
|
+
else:
|
|
186
|
+
print("Failed to start daemon.", file=sys.stderr)
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def stop_daemon(cwd: str | None = None) -> None:
|
|
191
|
+
cwd = cwd or str(Path.cwd())
|
|
192
|
+
if isinstance(cwd, Path):
|
|
193
|
+
cwd = str(cwd)
|
|
194
|
+
pid_path = _pid_path(cwd)
|
|
195
|
+
try:
|
|
196
|
+
pid_str = pid_path.read_text(encoding="utf-8").strip()
|
|
197
|
+
except OSError:
|
|
198
|
+
print("Daemon not running (no PID file).")
|
|
199
|
+
return
|
|
200
|
+
try:
|
|
201
|
+
pid = int(pid_str)
|
|
202
|
+
except ValueError:
|
|
203
|
+
print("Daemon not running (invalid PID file).")
|
|
204
|
+
pid_path.unlink(missing_ok=True)
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
os.kill(pid, signal.SIGTERM)
|
|
208
|
+
pid_path.unlink(missing_ok=True)
|
|
209
|
+
print("Dryft daemon stopped (PID", pid, ").")
|
|
210
|
+
except ProcessLookupError:
|
|
211
|
+
pid_path.unlink(missing_ok=True)
|
|
212
|
+
print("Daemon was not running (stale PID file removed).")
|
|
213
|
+
except OSError as e:
|
|
214
|
+
print("Could not stop daemon:", e, file=sys.stderr)
|
|
215
|
+
sys.exit(1)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-based change detection: git status, git diff, function/class extraction from diffs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitChange(TypedDict):
|
|
11
|
+
file: str
|
|
12
|
+
changeType: str
|
|
13
|
+
functionsModified: list[str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
FUNCTION_REGEXES = [
|
|
17
|
+
re.compile(r"^\s*(?:function|async\s+function)\s+(\w+)\s*\("),
|
|
18
|
+
re.compile(r"^\s*class\s+(\w+)"),
|
|
19
|
+
re.compile(r"^\s*def\s+(\w+)\s*\("),
|
|
20
|
+
re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\("),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_functions_from_diff_hunk(hunk_lines: list[str]) -> list[str]:
|
|
25
|
+
names: set[str] = set()
|
|
26
|
+
for line in hunk_lines:
|
|
27
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
28
|
+
content = line[1:]
|
|
29
|
+
for pattern in FUNCTION_REGEXES:
|
|
30
|
+
m = pattern.search(content)
|
|
31
|
+
if m and m.lastindex:
|
|
32
|
+
names.add(m.group(1))
|
|
33
|
+
return list(names)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_change_type(x: str, y: str) -> str:
|
|
37
|
+
if x == "D" or y == "D":
|
|
38
|
+
return "deleted"
|
|
39
|
+
if x == "A" or x == "?" or y == "?":
|
|
40
|
+
return "new_file"
|
|
41
|
+
return "modification"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_changes(cwd: str) -> list[GitChange]:
|
|
45
|
+
try:
|
|
46
|
+
r = subprocess.run(
|
|
47
|
+
["git", "status", "--porcelain"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
)
|
|
52
|
+
if r.returncode != 0:
|
|
53
|
+
return []
|
|
54
|
+
out = r.stdout or ""
|
|
55
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
56
|
+
return []
|
|
57
|
+
lines = [s for s in out.strip().split("\n") if s]
|
|
58
|
+
results: list[GitChange] = []
|
|
59
|
+
for line in lines:
|
|
60
|
+
xy = line[:2]
|
|
61
|
+
file_path = line[3:].strip()
|
|
62
|
+
if not file_path:
|
|
63
|
+
continue
|
|
64
|
+
x, y = xy[0], xy[1]
|
|
65
|
+
change_type = _get_change_type(x, y)
|
|
66
|
+
functions_modified: list[str] = []
|
|
67
|
+
try:
|
|
68
|
+
r = subprocess.run(
|
|
69
|
+
["git", "diff", "HEAD", "--", file_path],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
cwd=cwd,
|
|
73
|
+
)
|
|
74
|
+
diff_out = r.stdout or ""
|
|
75
|
+
added_lines = [l for l in diff_out.split("\n") if l.startswith("+")]
|
|
76
|
+
functions_modified = _extract_functions_from_diff_hunk(added_lines)
|
|
77
|
+
if not functions_modified and change_type in ("modification", "new_file"):
|
|
78
|
+
functions_modified = ["(whole file)"]
|
|
79
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
80
|
+
if change_type == "new_file":
|
|
81
|
+
functions_modified = ["(whole file)"]
|
|
82
|
+
results.append({
|
|
83
|
+
"file": file_path,
|
|
84
|
+
"changeType": change_type,
|
|
85
|
+
"functionsModified": functions_modified,
|
|
86
|
+
})
|
|
87
|
+
return results
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dryft init: create .dryft/, context.md, intent.md, .gitignore, inject agent templates, save config.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from .config import ensure_dryft_dir, save_config
|
|
10
|
+
|
|
11
|
+
DRYFT_DIR = ".dryft"
|
|
12
|
+
DRYFT_BLOCK_MARKER = "## Dryft Integration"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_template(name: str) -> str:
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
raw = files("dryft").joinpath("templates", name).read_text(encoding="utf-8")
|
|
18
|
+
return _strip_comment_first_line(raw).strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_comment_first_line(content: str) -> str:
|
|
22
|
+
trimmed = content.lstrip()
|
|
23
|
+
if trimmed.startswith("<!--") and "-->" in trimmed:
|
|
24
|
+
after = trimmed[trimmed.index("-->") + 3 :].lstrip()
|
|
25
|
+
return after
|
|
26
|
+
return trimmed
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _inject_into_file(cwd: str, target_filename: str, block: str) -> Literal["injected", "skipped", "created"]:
|
|
30
|
+
target = Path(cwd) / target_filename
|
|
31
|
+
existing = ""
|
|
32
|
+
try:
|
|
33
|
+
existing = target.read_text(encoding="utf-8")
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
if DRYFT_BLOCK_MARKER in existing:
|
|
37
|
+
return "skipped"
|
|
38
|
+
new_content = f"{existing.rstrip()}\n\n{block}" if existing else block
|
|
39
|
+
target.write_text(new_content, encoding="utf-8")
|
|
40
|
+
return "injected" if existing else "created"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
INITIAL_CONTEXT_MD = """# Dryft — Shared Development Context
|
|
44
|
+
|
|
45
|
+
> Not yet synced.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
INTENT_TEMPLATE = """# Intent
|
|
49
|
+
|
|
50
|
+
Agent-declared plan and active work. Update this file so the other developer's agent can avoid overlap.
|
|
51
|
+
|
|
52
|
+
**Agent:** Update the sections below when you start a task, when you change files, and when you finish a task.
|
|
53
|
+
|
|
54
|
+
## Current Task
|
|
55
|
+
|
|
56
|
+
(Describe what you're working on.)
|
|
57
|
+
|
|
58
|
+
## Planned Files
|
|
59
|
+
|
|
60
|
+
(Files you plan to modify next — one per line or bullet.)
|
|
61
|
+
|
|
62
|
+
## Dependencies
|
|
63
|
+
|
|
64
|
+
(Files or modules your work depends on.)
|
|
65
|
+
|
|
66
|
+
## Active Changes
|
|
67
|
+
|
|
68
|
+
(After modifying files: list functions/sections you changed.)
|
|
69
|
+
|
|
70
|
+
## Notes
|
|
71
|
+
|
|
72
|
+
(Optional.)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_default_developer_id() -> str:
|
|
77
|
+
return (
|
|
78
|
+
os.environ.get("DRYFT_DEVELOPER_ID")
|
|
79
|
+
or os.environ.get("USER")
|
|
80
|
+
or os.environ.get("USERNAME")
|
|
81
|
+
or (os.getlogin() if hasattr(os, "getlogin") else "developer")
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_init(cwd: str) -> None:
|
|
86
|
+
ensure_dryft_dir(cwd)
|
|
87
|
+
base = Path(cwd) / DRYFT_DIR
|
|
88
|
+
|
|
89
|
+
(base / "context.md").write_text(INITIAL_CONTEXT_MD, encoding="utf-8")
|
|
90
|
+
(base / "intent.md").write_text(INTENT_TEMPLATE, encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
gitignore = Path(cwd) / ".gitignore"
|
|
93
|
+
try:
|
|
94
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
95
|
+
if ".dryft" not in content:
|
|
96
|
+
addition = "\n.dryft/\n" if content.endswith("\n") else "\n.dryft/\n"
|
|
97
|
+
gitignore.write_text(content + addition, encoding="utf-8")
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
gitignore.write_text(".dryft/\n", encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
env_url = (os.environ.get("DRYFT_SERVER_URL") or "").strip()
|
|
102
|
+
env_project = (os.environ.get("DRYFT_PROJECT_ID") or "").strip()
|
|
103
|
+
default_dev = _get_default_developer_id()
|
|
104
|
+
if env_url and env_project:
|
|
105
|
+
server_url = env_url.rstrip("/")
|
|
106
|
+
project_id = env_project
|
|
107
|
+
developer_id = (os.environ.get("DRYFT_DEVELOPER_ID") or "").strip() or default_dev
|
|
108
|
+
else:
|
|
109
|
+
server_url = (
|
|
110
|
+
input("Server URL (e.g. https://your-app.railway.app) [{}]: ".format(env_url)).strip() or env_url or ""
|
|
111
|
+
).rstrip("/")
|
|
112
|
+
project_id = input("Project ID [{}]: ".format(env_project)).strip() or env_project or ""
|
|
113
|
+
developer_id = (
|
|
114
|
+
input("Your developer ID [{}]: ".format(default_dev)).strip() or default_dev
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not server_url or not project_id:
|
|
118
|
+
raise SystemExit("Server URL and Project ID are required. Re-run dryft config to set them.")
|
|
119
|
+
|
|
120
|
+
config = {
|
|
121
|
+
"server_url": server_url.rstrip("/"),
|
|
122
|
+
"project_id": project_id,
|
|
123
|
+
"developer_id": developer_id or _get_default_developer_id(),
|
|
124
|
+
"poll_interval_ms": 5_000,
|
|
125
|
+
}
|
|
126
|
+
save_config(cwd, config)
|
|
127
|
+
|
|
128
|
+
claude_block = _read_template("CLAUDE.md.dryft")
|
|
129
|
+
cursorrules_block = _read_template("cursorrules.dryft")
|
|
130
|
+
claude_result = _inject_into_file(cwd, "CLAUDE.md", claude_block)
|
|
131
|
+
cursor_result = _inject_into_file(cwd, ".cursorrules", cursorrules_block)
|
|
132
|
+
|
|
133
|
+
print("Dryft initialized in", base)
|
|
134
|
+
if claude_result in ("injected", "created"):
|
|
135
|
+
print("Dryft block added to CLAUDE.md")
|
|
136
|
+
elif claude_result == "skipped":
|
|
137
|
+
print("CLAUDE.md already contains Dryft integration (unchanged).")
|
|
138
|
+
if cursor_result in ("injected", "created"):
|
|
139
|
+
print("Dryft block added to .cursorrules")
|
|
140
|
+
elif cursor_result == "skipped":
|
|
141
|
+
print(".cursorrules already contains Dryft integration (unchanged).")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read and parse .dryft/intent.md (agent-declared plan, expected files, dependencies).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
INTENT_FILENAME = Path(".dryft") / "intent.md"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_list_lines(text: str) -> list[str]:
|
|
13
|
+
out = []
|
|
14
|
+
for line in text.split("\n"):
|
|
15
|
+
line = re.sub(r"^\s*[-*]\s+", "", re.sub(r"^\s*\d+\.\s*", "", line)).strip()
|
|
16
|
+
if line:
|
|
17
|
+
out.append(line)
|
|
18
|
+
return out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_section(content: str, heading: str) -> str:
|
|
22
|
+
pattern = re.compile(
|
|
23
|
+
r"##\s+" + re.escape(heading) + r"\s*\n([\s\S]*?)(?=##\s|$)",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
m = pattern.search(content)
|
|
27
|
+
return m.group(1).strip() if m else ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IntentData(TypedDict, total=False):
|
|
31
|
+
currentTask: str
|
|
32
|
+
plannedFiles: list[str]
|
|
33
|
+
dependencies: list[str]
|
|
34
|
+
activeChanges: str
|
|
35
|
+
notes: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_intent(cwd: str) -> IntentData | None:
|
|
39
|
+
path = Path(cwd) / INTENT_FILENAME
|
|
40
|
+
try:
|
|
41
|
+
content = path.read_text(encoding="utf-8")
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
current_task = _extract_section(content, "Current Task")
|
|
45
|
+
planned_files_text = _extract_section(content, "Planned Files")
|
|
46
|
+
dependencies_text = _extract_section(content, "Dependencies")
|
|
47
|
+
active_changes = _extract_section(content, "Active Changes")
|
|
48
|
+
notes = _extract_section(content, "Notes")
|
|
49
|
+
planned = _parse_list_lines(planned_files_text)
|
|
50
|
+
deps = _parse_list_lines(dependencies_text)
|
|
51
|
+
out: IntentData = {}
|
|
52
|
+
if current_task:
|
|
53
|
+
out["currentTask"] = current_task
|
|
54
|
+
if planned:
|
|
55
|
+
out["plannedFiles"] = planned
|
|
56
|
+
if deps:
|
|
57
|
+
out["dependencies"] = deps
|
|
58
|
+
if active_changes:
|
|
59
|
+
out["activeChanges"] = active_changes
|
|
60
|
+
if notes:
|
|
61
|
+
out["notes"] = notes
|
|
62
|
+
return out if out else None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- Insert this block into your project's CLAUDE.md for Dryft integration. -->
|
|
2
|
+
|
|
3
|
+
## Dryft Integration
|
|
4
|
+
|
|
5
|
+
**CRITICAL:** You MUST read .dryft/context.md before starting and MUST update .dryft/intent.md after any file change and when a task is complete. Do not skip these steps.
|
|
6
|
+
|
|
7
|
+
BEFORE starting any task:
|
|
8
|
+
1. Read .dryft/context.md to understand what the other developer's AI is working on.
|
|
9
|
+
2. If your task is the same or equivalent to another developer's task in context.md (their "Current Task" or work they list in "Currently Active Changes" / "Files They Plan to Modify Next" / or marked "Complete"): (a) Check whether the files, directories, or functions that work would touch exist locally. (b) If they exist locally — do NOT redo the work; tell your developer the task appears already done. (c) If they do NOT exist locally — do NOT implement. Dryft is preventing duplicate work: the changes are completed elsewhere but not on your branch. Tell your developer: "The other developer's work for this task is marked complete, but the relevant files don't exist on your local branch yet. Update your local branch (e.g. git pull) to get their changes, then you can proceed or ask me again."
|
|
10
|
+
3. Check "Currently Active Changes" AND "Files They Plan to Modify Next" for overlap with your planned files.
|
|
11
|
+
4. If overlap exists, tell your developer immediately before proceeding.
|
|
12
|
+
5. Write your plan to .dryft/intent.md — include your task, plan, expected files, and dependencies.
|
|
13
|
+
|
|
14
|
+
AFTER modifying any file:
|
|
15
|
+
1. Update .dryft/intent.md "Active Changes" section with what you changed — functions added/modified, exports changed, interfaces altered.
|
|
16
|
+
2. This is critical — the other agent relies on this to avoid conflicts.
|
|
17
|
+
|
|
18
|
+
IF you share a file with the other agent:
|
|
19
|
+
1. Read context.md to see exactly WHICH functions/sections they are modifying.
|
|
20
|
+
2. Work in DIFFERENT functions or sections of the same file.
|
|
21
|
+
3. If you must modify the SAME function, STOP and tell your developer — this is a merge conflict risk.
|
|
22
|
+
4. Do NOT change function signatures, interfaces, or exports that the other agent depends on.
|
|
23
|
+
|
|
24
|
+
DURING work:
|
|
25
|
+
1. Re-read .dryft/context.md periodically (at least before each new file modification).
|
|
26
|
+
2. If context.md shows a new warning, pause and inform your developer.
|
|
27
|
+
3. If the other developer completes changes to files you depend on, suggest pulling latest.
|
|
28
|
+
|
|
29
|
+
AFTER completing a task:
|
|
30
|
+
1. Update .dryft/intent.md to mark the task as complete.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- Insert this block into your project's .cursorrules for Dryft integration. -->
|
|
2
|
+
|
|
3
|
+
## Dryft Integration
|
|
4
|
+
|
|
5
|
+
**CRITICAL:** You MUST read .dryft/context.md before starting and MUST update .dryft/intent.md after any file change and when a task is complete. Do not skip these steps.
|
|
6
|
+
|
|
7
|
+
BEFORE starting any task:
|
|
8
|
+
1. Read .dryft/context.md to understand what the other developer's AI is working on.
|
|
9
|
+
2. If your task is the same or equivalent to another developer's task in context.md (their "Current Task" or work they list in "Currently Active Changes" / "Files They Plan to Modify Next" / or marked "Complete"): (a) Check whether the files, directories, or functions that work would touch exist locally. (b) If they exist locally — do NOT redo the work; tell your developer the task appears already done. (c) If they do NOT exist locally — do NOT implement. Dryft is preventing duplicate work: the changes are completed elsewhere but not on your branch. Tell your developer: "The other developer's work for this task is marked complete, but the relevant files don't exist on your local branch yet. Update your local branch (e.g. git pull) to get their changes, then you can proceed or ask me again."
|
|
10
|
+
3. Check "Currently Active Changes" AND "Files They Plan to Modify Next" for overlap with your planned files.
|
|
11
|
+
4. If overlap exists, tell your developer immediately before proceeding.
|
|
12
|
+
5. Write your plan to .dryft/intent.md — include your task, plan, expected files, and dependencies.
|
|
13
|
+
|
|
14
|
+
AFTER modifying any file:
|
|
15
|
+
1. Update .dryft/intent.md "Active Changes" section with what you changed — functions added/modified, exports changed, interfaces altered.
|
|
16
|
+
2. This is critical — the other agent relies on this to avoid conflicts.
|
|
17
|
+
|
|
18
|
+
IF you share a file with the other agent:
|
|
19
|
+
1. Read context.md to see exactly WHICH functions/sections they are modifying.
|
|
20
|
+
2. Work in DIFFERENT functions or sections of the same file.
|
|
21
|
+
3. If you must modify the SAME function, STOP and tell your developer — this is a merge conflict risk.
|
|
22
|
+
4. Do NOT change function signatures, interfaces, or exports that the other agent depends on.
|
|
23
|
+
|
|
24
|
+
DURING work:
|
|
25
|
+
1. Re-read .dryft/context.md periodically (at least before each new file modification).
|
|
26
|
+
2. If context.md shows a new warning, pause and inform your developer.
|
|
27
|
+
3. If the other developer completes changes to files you depend on, suggest pulling latest.
|
|
28
|
+
|
|
29
|
+
AFTER completing a task:
|
|
30
|
+
1. Update .dryft/intent.md to mark the task as complete.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dryft
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Dryft CLI and daemon — sync agent context with Dryft server
|
|
5
|
+
Author: Dryft
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dryft,ai,agents,coordination,git
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: certifi
|
|
18
|
+
|
|
19
|
+
# Dryft Python client
|
|
20
|
+
|
|
21
|
+
Same CLI as the Node client, installable via **pip** per the PRD:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install dryft
|
|
25
|
+
dryft init
|
|
26
|
+
dryft start
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From source (repo root):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e ./client-py
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Commands: `init`, `start`, `stop`, `status`, `config`, `push-notify`. Implementation follows the same phases as the Node client in `client/`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/dryft/__init__.py
|
|
4
|
+
src/dryft/api.py
|
|
5
|
+
src/dryft/cli.py
|
|
6
|
+
src/dryft/config.py
|
|
7
|
+
src/dryft/context.py
|
|
8
|
+
src/dryft/daemon.py
|
|
9
|
+
src/dryft/git.py
|
|
10
|
+
src/dryft/init_.py
|
|
11
|
+
src/dryft/intent.py
|
|
12
|
+
src/dryft.egg-info/PKG-INFO
|
|
13
|
+
src/dryft.egg-info/SOURCES.txt
|
|
14
|
+
src/dryft.egg-info/dependency_links.txt
|
|
15
|
+
src/dryft.egg-info/entry_points.txt
|
|
16
|
+
src/dryft.egg-info/requires.txt
|
|
17
|
+
src/dryft.egg-info/top_level.txt
|
|
18
|
+
src/dryft/templates/CLAUDE.md.dryft
|
|
19
|
+
src/dryft/templates/cursorrules.dryft
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
certifi
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dryft
|