@nidvue/teamwork 0.0.0-stage → 1.1.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.
- package/CHANGELOG.md +329 -0
- package/LICENSE +661 -0
- package/NOTICE +16 -0
- package/README.md +462 -2
- package/SECURITY.md +64 -0
- package/package.json +71 -4
- package/prompts/teamwork-pulse.md +12 -0
- package/prompts/teamwork-review.md +16 -0
- package/skills/teamwork/AGENTS.md +462 -0
- package/skills/teamwork/SKILL.md +795 -0
- package/skills/teamwork/references/Adapters.md +112 -0
- package/skills/teamwork/references/Audit.md +120 -0
- package/skills/teamwork/references/Concurrency.md +160 -0
- package/skills/teamwork/references/Dissent.md +85 -0
- package/skills/teamwork/references/Docs.md +74 -0
- package/skills/teamwork/references/Engineering.md +410 -0
- package/skills/teamwork/references/Evidence.md +140 -0
- package/skills/teamwork/references/Remotes.md +144 -0
- package/skills/teamwork/references/Roles.md +201 -0
- package/skills/teamwork/references/State.md +64 -0
- package/skills/teamwork/references/Testing.md +144 -0
- package/skills/teamwork/roles/architect.md +153 -0
- package/skills/teamwork/roles/ceo.md +218 -0
- package/skills/teamwork/roles/cfo.md +150 -0
- package/skills/teamwork/roles/coo.md +147 -0
- package/skills/teamwork/roles/cto.md +162 -0
- package/skills/teamwork/roles/data-engineer.md +120 -0
- package/skills/teamwork/roles/designer.md +349 -0
- package/skills/teamwork/roles/developer.md +166 -0
- package/skills/teamwork/roles/product-manager.md +151 -0
- package/skills/teamwork/roles/project-manager.md +159 -0
- package/skills/teamwork/roles/release-engineer.md +245 -0
- package/skills/teamwork/roles/reviewer.md +254 -0
- package/skills/teamwork/roles/security-auditor.md +157 -0
- package/skills/teamwork/roles/technical-writer.md +121 -0
- package/skills/teamwork/roles/tester.md +180 -0
- package/skills/teamwork/scripts/build_pi_package.py +200 -0
- package/skills/teamwork/scripts/privacy_scan.py +1185 -0
- package/skills/teamwork/scripts/sync_skill.py +108 -0
- package/skills/teamwork/scripts/teamwork.py +28 -0
- package/skills/teamwork/scripts/verify_release.py +502 -0
- package/skills/teamwork/src/teamwork/__init__.py +18 -0
- package/skills/teamwork/src/teamwork/__main__.py +10 -0
- package/skills/teamwork/src/teamwork/bundle.py +157 -0
- package/skills/teamwork/src/teamwork/cli.py +16 -0
- package/skills/teamwork/src/teamwork/config.py +396 -0
- package/skills/teamwork/src/teamwork/engine.py +10119 -0
- package/skills/teamwork/src/teamwork/gitutil.py +172 -0
- package/skills/teamwork/src/teamwork/imaging.py +124 -0
- package/skills/teamwork/src/teamwork/io.py +144 -0
- package/skills/teamwork/src/teamwork/layout.py +253 -0
- package/skills/teamwork/src/teamwork/names.py +191 -0
- package/skills/teamwork/src/teamwork/portable.py +79 -0
- package/skills/teamwork/src/teamwork/records.py +1220 -0
- package/skills/teamwork/src/teamwork/resources.py +120 -0
- package/skills/teamwork/src/teamwork/version.py +13 -0
- package/skills/teamwork/templates/AGENTS.md +460 -0
- package/skills/teamwork/templates/Docs/Architecture.md +40 -0
- package/skills/teamwork/templates/Docs/Archive.md +8 -0
- package/skills/teamwork/templates/Docs/Board.md +28 -0
- package/skills/teamwork/templates/Docs/Costs.md +30 -0
- package/skills/teamwork/templates/Docs/Decisions.md +26 -0
- package/skills/teamwork/templates/Docs/Design.md +106 -0
- package/skills/teamwork/templates/Docs/Plan.md +24 -0
- package/skills/teamwork/templates/Docs/Project.md +40 -0
- package/skills/teamwork/templates/Docs/Release.md +43 -0
- package/skills/teamwork/templates/Docs/Research.md +36 -0
- package/skills/teamwork/templates/Docs/Risks.md +18 -0
- package/skills/teamwork/templates/TeamMap.md +100 -0
- package/skills/teamwork-pi/SKILL.md +77 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Git plumbing used by the branch/PR/merge flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .io import write_lf
|
|
10
|
+
from .names import slugify
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE = "main"
|
|
13
|
+
|
|
14
|
+
LFS_PATTERNS = (
|
|
15
|
+
"*.png",
|
|
16
|
+
"*.jpg",
|
|
17
|
+
"*.jpeg",
|
|
18
|
+
"*.gif",
|
|
19
|
+
"*.webp",
|
|
20
|
+
"*.ico",
|
|
21
|
+
"*.bmp",
|
|
22
|
+
"*.tif",
|
|
23
|
+
"*.tiff",
|
|
24
|
+
"*.psd",
|
|
25
|
+
"*.ai",
|
|
26
|
+
"*.sketch",
|
|
27
|
+
"*.fig",
|
|
28
|
+
"*.mp4",
|
|
29
|
+
"*.mov",
|
|
30
|
+
"*.webm",
|
|
31
|
+
"*.mkv",
|
|
32
|
+
"*.avi",
|
|
33
|
+
"*.mp3",
|
|
34
|
+
"*.wav",
|
|
35
|
+
"*.flac",
|
|
36
|
+
"*.ogg",
|
|
37
|
+
"*.pdf",
|
|
38
|
+
"*.zip",
|
|
39
|
+
"*.tar",
|
|
40
|
+
"*.tar.gz",
|
|
41
|
+
"*.tgz",
|
|
42
|
+
"*.gz",
|
|
43
|
+
"*.bz2",
|
|
44
|
+
"*.7z",
|
|
45
|
+
"*.rar",
|
|
46
|
+
"*.whl",
|
|
47
|
+
"*.so",
|
|
48
|
+
"*.dylib",
|
|
49
|
+
"*.dll",
|
|
50
|
+
"*.a",
|
|
51
|
+
"*.o",
|
|
52
|
+
"*.jar",
|
|
53
|
+
"*.wasm",
|
|
54
|
+
"*.onnx",
|
|
55
|
+
"*.pt",
|
|
56
|
+
"*.pth",
|
|
57
|
+
"*.safetensors",
|
|
58
|
+
"*.ckpt",
|
|
59
|
+
"*.bin",
|
|
60
|
+
"*.gguf",
|
|
61
|
+
"*.parquet",
|
|
62
|
+
"*.arrow",
|
|
63
|
+
"*.feather",
|
|
64
|
+
"*.sqlite",
|
|
65
|
+
"*.db",
|
|
66
|
+
"*.ttf",
|
|
67
|
+
"*.otf",
|
|
68
|
+
"*.woff",
|
|
69
|
+
"*.woff2",
|
|
70
|
+
"*.glb",
|
|
71
|
+
"*.gltf",
|
|
72
|
+
"*.obj",
|
|
73
|
+
"*.fbx",
|
|
74
|
+
"*.blend",
|
|
75
|
+
"*.exr",
|
|
76
|
+
"*.hdr",
|
|
77
|
+
"*.unitypackage",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_git(project: Path, *argv: str):
|
|
82
|
+
return subprocess.run(["git", "-C", str(project), *argv], capture_output=True, text=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_git_repo(project: Path) -> bool:
|
|
86
|
+
r = run_git(project, "rev-parse", "--is-inside-work-tree")
|
|
87
|
+
return r.returncode == 0 and r.stdout.strip() == "true"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def current_branch(project: Path) -> str:
|
|
91
|
+
return run_git(project, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def branch_exists(project: Path, branch: str) -> bool:
|
|
95
|
+
r = run_git(project, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
|
|
96
|
+
return r.returncode == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def branch_merged(project: Path, branch: str, base: str = DEFAULT_BASE) -> bool:
|
|
100
|
+
if not branch or not branch_exists(project, branch):
|
|
101
|
+
return False
|
|
102
|
+
r = run_git(project, "branch", "--merged", base)
|
|
103
|
+
return any(line.strip().lstrip("* ").strip() == branch for line in r.stdout.splitlines())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def branch_worktree(project: Path, branch: str):
|
|
107
|
+
"""The worktree that has ``branch`` checked out, or ``None``."""
|
|
108
|
+
current = ""
|
|
109
|
+
for line in run_git(project, "worktree", "list", "--porcelain").stdout.splitlines():
|
|
110
|
+
if line.startswith("worktree "):
|
|
111
|
+
current = line.split(" ", 1)[1].strip()
|
|
112
|
+
elif line.strip() == f"branch refs/heads/{branch}" and current:
|
|
113
|
+
return Path(current)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_lfs(project: Path, quiet: bool = False) -> dict:
|
|
118
|
+
"""Enable Git LFS locally and keep .gitattributes in sync."""
|
|
119
|
+
result = {"available": False, "installed": False, "patterns_added": 0}
|
|
120
|
+
probe = subprocess.run(["git", "lfs", "version"], capture_output=True, text=True)
|
|
121
|
+
if probe.returncode != 0:
|
|
122
|
+
if not quiet:
|
|
123
|
+
print(
|
|
124
|
+
"teamwork: WARNING git-lfs is not installed; large binaries will be"
|
|
125
|
+
" committed as plain blobs. Install git-lfs for archival."
|
|
126
|
+
)
|
|
127
|
+
return result
|
|
128
|
+
result["available"] = True
|
|
129
|
+
inst = subprocess.run(
|
|
130
|
+
["git", "-C", str(project), "lfs", "install", "--local"], capture_output=True, text=True
|
|
131
|
+
)
|
|
132
|
+
result["installed"] = inst.returncode == 0
|
|
133
|
+
|
|
134
|
+
path = project / ".gitattributes"
|
|
135
|
+
existing = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
136
|
+
have = {line.split()[0] for line in existing if line.strip() and not line.startswith("#")}
|
|
137
|
+
missing = [pat for pat in LFS_PATTERNS if pat not in have]
|
|
138
|
+
if missing:
|
|
139
|
+
lines = list(existing)
|
|
140
|
+
if not any("teamwork: LFS" in line for line in lines):
|
|
141
|
+
lines += ["", "# --- teamwork: Git LFS tracked content ---"]
|
|
142
|
+
lines += [f"{pat} filter=lfs diff=lfs merge=lfs -text" for pat in missing]
|
|
143
|
+
write_lf(path, "\n".join(lines).rstrip() + "\n")
|
|
144
|
+
result["patterns_added"] = len(missing)
|
|
145
|
+
if not quiet:
|
|
146
|
+
print(
|
|
147
|
+
f"teamwork: git-lfs ready (local install={result['installed']}, "
|
|
148
|
+
f"{result['patterns_added']} pattern(s) added to .gitattributes)"
|
|
149
|
+
)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def lfs_tracked(project: Path, rel: str) -> bool:
|
|
154
|
+
path = project / ".gitattributes"
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return False
|
|
157
|
+
name = Path(rel).name
|
|
158
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
159
|
+
parts = line.split()
|
|
160
|
+
if len(parts) >= 2 and "filter=lfs" in line:
|
|
161
|
+
pattern = parts[0]
|
|
162
|
+
if pattern.startswith("*."):
|
|
163
|
+
if name.endswith(pattern[1:]):
|
|
164
|
+
return True
|
|
165
|
+
elif Path(rel).match(pattern):
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def branch_name(instance: str, task_id: str, title: str) -> str:
|
|
171
|
+
who = re.sub(r"[^a-z0-9]+", "-", (instance or "agent").lower()).strip("-")
|
|
172
|
+
return f"{who}/{task_id.lower()}-{slugify(title)}"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Offline image-model capability probe (no network, no prompt leak)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
IMAGE_MODEL_RANKING = {
|
|
10
|
+
"openai": ("gpt-image-1", "dall-e-3"),
|
|
11
|
+
"google": (
|
|
12
|
+
"imagen-4.0-ultra-generate-001",
|
|
13
|
+
"imagen-4.0-generate-001",
|
|
14
|
+
"gemini-2.5-flash-image",
|
|
15
|
+
),
|
|
16
|
+
"black-forest-labs": ("flux-1.1-pro", "flux-1-dev", "flux-1-schnell"),
|
|
17
|
+
"stability": ("stable-image-ultra", "sd3.5-large", "sd3.5-medium"),
|
|
18
|
+
"replicate": (
|
|
19
|
+
"black-forest-labs/flux-1.1-pro",
|
|
20
|
+
"stability-ai/stable-diffusion-3.5-large",
|
|
21
|
+
),
|
|
22
|
+
"local": ("qwen-image-2.1", "sdxl", "sd3.5-large"),
|
|
23
|
+
"ollama-cloud": (), # text/vision INPUT only as of 2026-10-17
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
IMAGE_PROVIDER_PRIORITY = (
|
|
27
|
+
"openai",
|
|
28
|
+
"google",
|
|
29
|
+
"black-forest-labs",
|
|
30
|
+
"stability",
|
|
31
|
+
"replicate",
|
|
32
|
+
"local",
|
|
33
|
+
"ollama-cloud",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
IMAGE_PROVIDER_CREDENTIALS = {
|
|
37
|
+
"openai": ("OPENAI_API_KEY",),
|
|
38
|
+
"google": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
|
39
|
+
"black-forest-labs": ("BFL_API_KEY", "BLACK_FOREST_LABS_API_KEY"),
|
|
40
|
+
"stability": ("STABILITY_API_KEY",),
|
|
41
|
+
"replicate": ("REPLICATE_API_TOKEN",),
|
|
42
|
+
"ollama-cloud": ("OLLAMA_API_KEY",),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
IMAGE_FALLBACK_HINT = (
|
|
46
|
+
"no cloud image model detected: fall back to a local model, then to a "
|
|
47
|
+
"hand-authored SVG (roles/designer.md, fallback ladder). Do not stall, and "
|
|
48
|
+
"do not report an image that was never generated."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_image_provider(env=None) -> str:
|
|
53
|
+
"""First provider in priority order that is configured and has a model."""
|
|
54
|
+
env = os.environ if env is None else env
|
|
55
|
+
for provider in IMAGE_PROVIDER_PRIORITY:
|
|
56
|
+
if provider == "local":
|
|
57
|
+
if shutil.which("qimg") or any(key.startswith("QIMG_") for key in env):
|
|
58
|
+
return provider
|
|
59
|
+
continue
|
|
60
|
+
if not IMAGE_MODEL_RANKING.get(provider):
|
|
61
|
+
continue
|
|
62
|
+
if any(env.get(name) for name in IMAGE_PROVIDER_CREDENTIALS.get(provider, ())):
|
|
63
|
+
return provider
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_image_model(env=None) -> dict:
|
|
68
|
+
"""Decide which image generator to use, without touching the network."""
|
|
69
|
+
env = os.environ if env is None else env
|
|
70
|
+
override = (env.get("TEAMWORK_IMAGE_MODEL") or "").strip()
|
|
71
|
+
if override:
|
|
72
|
+
provider = (env.get("TEAMWORK_IMAGE_PROVIDER") or "custom").strip() or "custom"
|
|
73
|
+
return {
|
|
74
|
+
"provider": provider,
|
|
75
|
+
"model": override,
|
|
76
|
+
"ranked": [],
|
|
77
|
+
"source": "TEAMWORK_IMAGE_MODEL override",
|
|
78
|
+
"fallback": "",
|
|
79
|
+
}
|
|
80
|
+
explicit = (env.get("TEAMWORK_IMAGE_PROVIDER") or "").strip()
|
|
81
|
+
if explicit:
|
|
82
|
+
ranked = list(IMAGE_MODEL_RANKING.get(explicit, ()))
|
|
83
|
+
return {
|
|
84
|
+
"provider": explicit,
|
|
85
|
+
"model": ranked[0] if ranked else "",
|
|
86
|
+
"ranked": ranked,
|
|
87
|
+
"source": "TEAMWORK_IMAGE_PROVIDER",
|
|
88
|
+
"fallback": "" if ranked else IMAGE_FALLBACK_HINT,
|
|
89
|
+
}
|
|
90
|
+
provider = detect_image_provider(env)
|
|
91
|
+
ranked = list(IMAGE_MODEL_RANKING.get(provider, ()))
|
|
92
|
+
return {
|
|
93
|
+
"provider": provider,
|
|
94
|
+
"model": ranked[0] if ranked else "",
|
|
95
|
+
"ranked": ranked,
|
|
96
|
+
"source": "ambient environment" if provider else "none detected",
|
|
97
|
+
"fallback": "" if provider else IMAGE_FALLBACK_HINT,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_imaging(args) -> int:
|
|
102
|
+
"""`teamwork imaging probe` - which image model, if any, is available."""
|
|
103
|
+
|
|
104
|
+
decision = resolve_image_model()
|
|
105
|
+
if getattr(args, "json", False):
|
|
106
|
+
print(json.dumps({"command": "imaging.probe", **decision}, indent=2))
|
|
107
|
+
return 0
|
|
108
|
+
print("# imaging probe (offline: environment + PATH only)")
|
|
109
|
+
print(f" provider: {decision['provider'] or '-'}")
|
|
110
|
+
print(f" model: {decision['model'] or '-'}")
|
|
111
|
+
print(f" source: {decision['source']}")
|
|
112
|
+
if decision["ranked"]:
|
|
113
|
+
print(" ranked: " + " > ".join(decision["ranked"]))
|
|
114
|
+
if decision["model"]:
|
|
115
|
+
print(" supports: text-to-image, image-to-image")
|
|
116
|
+
else:
|
|
117
|
+
print(" supports: none")
|
|
118
|
+
if decision["fallback"]:
|
|
119
|
+
print(f" fallback: {decision['fallback']}")
|
|
120
|
+
print(
|
|
121
|
+
" policy: roles/designer.md (plan, 4+ candidates, visual review,"
|
|
122
|
+
" improvement, privacy and licence red lines)"
|
|
123
|
+
)
|
|
124
|
+
return 0
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Atomic file IO, the exclusive lock and the one error exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .portable import newline_for, pid_alive
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
16
|
+
from typing import NoReturn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
LOCK_TIMEOUT = float(os.environ.get("TEAMWORK_LOCK_TIMEOUT", "60"))
|
|
20
|
+
|
|
21
|
+
LOCK_STALE_SECONDS = float(os.environ.get("TEAMWORK_LOCK_STALE_SECONDS", "300"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _lock_owner_stale(path: Path) -> bool:
|
|
25
|
+
"""True only when the lock is clearly abandoned: old AND its owner is gone."""
|
|
26
|
+
try:
|
|
27
|
+
age = time.time() - path.stat().st_mtime
|
|
28
|
+
body = path.read_text(encoding="utf-8").strip()
|
|
29
|
+
except OSError:
|
|
30
|
+
return False
|
|
31
|
+
if age < LOCK_STALE_SECONDS:
|
|
32
|
+
return False
|
|
33
|
+
pid = int(body.split(".", 1)[0]) if body.split(".", 1)[0].isdigit() else 0
|
|
34
|
+
if pid <= 0:
|
|
35
|
+
return True
|
|
36
|
+
# Portable liveness probe: on Windows os.kill(pid, 0) is not a probe, so
|
|
37
|
+
# this goes through the audited helper in teamwork.portable.
|
|
38
|
+
return not pid_alive(pid)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextlib.contextmanager
|
|
42
|
+
def lock(path: Path, timeout: float | None = None):
|
|
43
|
+
"""Exclusive lock via O_CREAT|O_EXCL. Never steals a live lock, and only
|
|
44
|
+
removes the lock file if this process owns it."""
|
|
45
|
+
if timeout is None:
|
|
46
|
+
timeout = LOCK_TIMEOUT
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
token = f"{os.getpid()}.{time.time():.6f}"
|
|
49
|
+
deadline = time.monotonic() + max(timeout, 0.0)
|
|
50
|
+
while True:
|
|
51
|
+
try:
|
|
52
|
+
fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
53
|
+
os.write(fd, token.encode())
|
|
54
|
+
os.close(fd)
|
|
55
|
+
break
|
|
56
|
+
except FileExistsError:
|
|
57
|
+
if _lock_owner_stale(path):
|
|
58
|
+
try:
|
|
59
|
+
os.unlink(str(path))
|
|
60
|
+
continue
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
if time.monotonic() > deadline:
|
|
64
|
+
die(
|
|
65
|
+
f"timed out after {int(timeout)}s waiting for {path.name}. "
|
|
66
|
+
"Another agent holds it. Wait, or clear it only if the owner "
|
|
67
|
+
"is gone. Never delete a lock that is still in use.",
|
|
68
|
+
4,
|
|
69
|
+
)
|
|
70
|
+
time.sleep(0.05)
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
try:
|
|
75
|
+
if path.read_text(encoding="utf-8").strip() == token:
|
|
76
|
+
path.unlink()
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
82
|
+
"""Write via a unique temp file in the same directory, then rename.
|
|
83
|
+
|
|
84
|
+
Windows rejects ``os.replace`` while another process has the target open, so
|
|
85
|
+
the rename is retried briefly. The original mode is preserved because
|
|
86
|
+
``mkstemp`` creates a 0600 file, which would otherwise make a previously
|
|
87
|
+
group-readable workspace file private after the first rewrite.
|
|
88
|
+
"""
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
if path.exists():
|
|
91
|
+
try:
|
|
92
|
+
mode = path.stat().st_mode & 0o777
|
|
93
|
+
except OSError:
|
|
94
|
+
mode = 0o644
|
|
95
|
+
else:
|
|
96
|
+
# Respect the caller's umask instead of forcing a world-readable file.
|
|
97
|
+
current_umask = os.umask(0)
|
|
98
|
+
os.umask(current_umask)
|
|
99
|
+
mode = 0o666 & ~current_umask
|
|
100
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
|
|
101
|
+
try:
|
|
102
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline=newline_for()) as fh:
|
|
103
|
+
fh.write(text)
|
|
104
|
+
try:
|
|
105
|
+
os.chmod(tmp, mode)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
for attempt in range(5):
|
|
109
|
+
try:
|
|
110
|
+
os.replace(tmp, str(path))
|
|
111
|
+
break
|
|
112
|
+
except PermissionError:
|
|
113
|
+
if attempt == 4:
|
|
114
|
+
raise
|
|
115
|
+
time.sleep(0.05 * (attempt + 1))
|
|
116
|
+
except OSError:
|
|
117
|
+
# Windows file-sharing violations are not always PermissionError.
|
|
118
|
+
if os.name != "nt" or attempt == 4:
|
|
119
|
+
raise
|
|
120
|
+
time.sleep(0.05 * (attempt + 1))
|
|
121
|
+
except BaseException:
|
|
122
|
+
try:
|
|
123
|
+
os.unlink(tmp)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def write_lf(path: Path, text: str, encoding: str = "utf-8") -> None:
|
|
130
|
+
"""Write ``text`` with LF newlines on every platform and every Python >= 3.9.
|
|
131
|
+
|
|
132
|
+
``Path.write_text(..., newline=...)`` only exists from Python 3.10, and this
|
|
133
|
+
package supports 3.9 (CI runs the matrix on 3.9), so the file is opened here
|
|
134
|
+
directly. On Windows the default text mode would translate ``\n`` to CRLF and
|
|
135
|
+
a clone would no longer be byte-identical across platforms.
|
|
136
|
+
"""
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
with path.open("w", encoding=encoding, newline="\n") as handle:
|
|
139
|
+
handle.write(text)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def die(msg: str, code: int = 1) -> NoReturn:
|
|
143
|
+
print(f"teamwork: {msg}", file=sys.stderr)
|
|
144
|
+
raise SystemExit(code)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Workspace layout: the team tree, committed docs and their links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import (
|
|
9
|
+
DIR_AGENTS,
|
|
10
|
+
DIR_DOCS,
|
|
11
|
+
DIR_GLOBAL,
|
|
12
|
+
DIR_ROLES,
|
|
13
|
+
DIR_RUNTIME,
|
|
14
|
+
DIR_WORKTREES,
|
|
15
|
+
F_CRITERIA,
|
|
16
|
+
F_DISPUTES,
|
|
17
|
+
F_GLOBAL,
|
|
18
|
+
F_GLOBAL_ARCHIVE,
|
|
19
|
+
F_INBOX,
|
|
20
|
+
F_LONG,
|
|
21
|
+
F_MAP,
|
|
22
|
+
F_MEM_ARCHIVE,
|
|
23
|
+
F_REVIEWS,
|
|
24
|
+
F_ROLE,
|
|
25
|
+
F_SHORT,
|
|
26
|
+
F_SKILLS,
|
|
27
|
+
F_TASKS,
|
|
28
|
+
F_TESTPLAN,
|
|
29
|
+
F_TESTS,
|
|
30
|
+
F_WORKLOG,
|
|
31
|
+
SKILLS_DIR,
|
|
32
|
+
TEAM_ROOT_DIR,
|
|
33
|
+
TEAMS_SUBDIR,
|
|
34
|
+
)
|
|
35
|
+
from .io import die
|
|
36
|
+
from .names import pascal, resolve_role, safe_component, team_slug
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project_path(args) -> Path:
|
|
40
|
+
if getattr(args, "project", None):
|
|
41
|
+
return Path(args.project).expanduser().resolve()
|
|
42
|
+
return Path.cwd()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def project_of(root: Path) -> Path:
|
|
46
|
+
"""<project>/.pi/Teamwork/Teams/<Team> -> <project>."""
|
|
47
|
+
return root.parents[3]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def docs_dir(root: Path) -> Path:
|
|
51
|
+
"""Committed project output. The team's own tree stays in .pi/Teamwork."""
|
|
52
|
+
return project_of(root).joinpath(*DIR_DOCS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def work_repo(root: Path, cwd: Path | None = None) -> Path:
|
|
56
|
+
"""The git work tree this command should act on.
|
|
57
|
+
|
|
58
|
+
Inside one of our per-instance worktrees that worktree is the answer;
|
|
59
|
+
otherwise it is the project that owns the team tree.
|
|
60
|
+
"""
|
|
61
|
+
cwd = (cwd or Path.cwd()).resolve()
|
|
62
|
+
wt_root = root / DIR_RUNTIME / DIR_WORKTREES
|
|
63
|
+
if wt_root.is_dir():
|
|
64
|
+
for parent in [cwd, *cwd.parents]:
|
|
65
|
+
try:
|
|
66
|
+
rel = parent.relative_to(wt_root)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
if rel.parts:
|
|
70
|
+
cand = wt_root / rel.parts[0]
|
|
71
|
+
if (cand / ".git").exists():
|
|
72
|
+
return cand
|
|
73
|
+
return project_of(root)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def team_root_from_cwd(cwd: Path) -> Path | None:
|
|
77
|
+
"""If we are inside one of our own worktrees, use the team that owns it.
|
|
78
|
+
|
|
79
|
+
`<team>/Runtime/Worktrees/<Instance>/...` -> `<team>`; anything else -> None.
|
|
80
|
+
Without this, running a command from a worktree would look for (and create)
|
|
81
|
+
a second, unrelated team tree.
|
|
82
|
+
"""
|
|
83
|
+
for parent in [cwd, *cwd.parents]:
|
|
84
|
+
if parent.name == DIR_WORKTREES and parent.parent.name == DIR_RUNTIME:
|
|
85
|
+
candidate = parent.parent.parent
|
|
86
|
+
if (candidate / DIR_ROLES).is_dir() or (candidate / F_MAP).exists():
|
|
87
|
+
return candidate
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def project_root(args) -> Path:
|
|
92
|
+
"""The current team's hidden tree:
|
|
93
|
+
<project>/.pi/Teamwork/Teams/<Team>/"""
|
|
94
|
+
if os.environ.get("TEAMWORK_ROOT"):
|
|
95
|
+
return Path(os.environ["TEAMWORK_ROOT"]).expanduser().resolve()
|
|
96
|
+
if not getattr(args, "project", None):
|
|
97
|
+
found = team_root_from_cwd(Path.cwd().resolve())
|
|
98
|
+
if found is not None:
|
|
99
|
+
return found
|
|
100
|
+
project = project_path(args)
|
|
101
|
+
slug = team_slug(project, getattr(args, "team", "") or "")
|
|
102
|
+
return (project / TEAM_ROOT_DIR / TEAMS_SUBDIR / slug).resolve()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def teams_root_unused(project: Path) -> Path:
|
|
106
|
+
return project / TEAM_ROOT_DIR / TEAMS_SUBDIR
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def agents_dir(root: Path) -> Path:
|
|
110
|
+
return root / DIR_AGENTS
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def agent_dir(root: Path, agent: str) -> Path:
|
|
114
|
+
"""An instance directory: private scratch, artifacts, reports, worklog."""
|
|
115
|
+
return agents_dir(root) / safe_component(pascal(agent))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def roles_dir(root: Path) -> Path:
|
|
119
|
+
return root / DIR_ROLES
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def role_dir(root: Path, role: str) -> Path:
|
|
123
|
+
"""A role directory: memory and the task list, SHARED by every instance of
|
|
124
|
+
that role. developer-1 and developer-2 share one memory and one queue."""
|
|
125
|
+
return roles_dir(root) / safe_component(pascal(resolve_role(role)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def global_dir(root: Path) -> Path:
|
|
129
|
+
return root / DIR_GLOBAL
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_agents(root: Path) -> list:
|
|
133
|
+
"""Instance names (developer-1, developer-2, ...)."""
|
|
134
|
+
d = agents_dir(root)
|
|
135
|
+
if not d.is_dir():
|
|
136
|
+
return []
|
|
137
|
+
return sorted(p.name for p in d.iterdir() if p.is_dir())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_roles(root: Path) -> list:
|
|
141
|
+
"""Canonical role slugs that own a shared memory + task queue."""
|
|
142
|
+
d = roles_dir(root)
|
|
143
|
+
if not d.is_dir():
|
|
144
|
+
return []
|
|
145
|
+
return sorted({resolve_role(p.name) for p in d.iterdir() if p.is_dir()})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def instances_of(root: Path, role: str) -> list:
|
|
149
|
+
role = resolve_role(role)
|
|
150
|
+
return [n for n in list_agents(root) if resolve_role(n) == role]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def known_instances(root: Path) -> list:
|
|
154
|
+
return sorted(i for r in list_roles(root) for i in instances_of(root, r))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def team_root_from(path: Path) -> Path:
|
|
158
|
+
"""Recover the team root from any generated file inside the team tree.
|
|
159
|
+
|
|
160
|
+
Roles/<Role>/X -> root ; Agents/<I>/X -> root ; Global/X -> root ; root/X -> root
|
|
161
|
+
"""
|
|
162
|
+
parent = path.parent
|
|
163
|
+
if parent.parent.name in (DIR_ROLES, DIR_AGENTS):
|
|
164
|
+
return parent.parent.parent
|
|
165
|
+
if parent.name == DIR_GLOBAL:
|
|
166
|
+
return parent.parent
|
|
167
|
+
return parent
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _rel(path: Path, target: Path) -> str:
|
|
171
|
+
return os.path.relpath(target, start=path.parent).replace(os.sep, "/")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def link_fields(path: Path, owner: str) -> dict:
|
|
175
|
+
"""Relative links that stay correct at any depth in the team tree."""
|
|
176
|
+
root = team_root_from(path)
|
|
177
|
+
role = resolve_role(owner)
|
|
178
|
+
docs = docs_dir(root) if root.parent.name == TEAMS_SUBDIR else root
|
|
179
|
+
return {
|
|
180
|
+
"tasks": _rel(path, role_dir(root, role) / F_TASKS),
|
|
181
|
+
"short": _rel(path, role_dir(root, role) / F_SHORT),
|
|
182
|
+
"long": _rel(path, role_dir(root, role) / F_LONG),
|
|
183
|
+
"mem_archive": _rel(path, role_dir(root, role) / F_MEM_ARCHIVE),
|
|
184
|
+
"role": _rel(path, role_dir(root, role) / F_ROLE),
|
|
185
|
+
"global_mem": _rel(path, global_dir(root) / F_GLOBAL),
|
|
186
|
+
"global_archive": _rel(path, global_dir(root) / F_GLOBAL_ARCHIVE),
|
|
187
|
+
"disputes": _rel(path, root / F_DISPUTES),
|
|
188
|
+
"map": _rel(path, root / F_MAP),
|
|
189
|
+
"project": _rel(path, docs / "Project.md"),
|
|
190
|
+
"plan": _rel(path, docs / "Plan.md"),
|
|
191
|
+
"architecture": _rel(path, docs / "Architecture.md"),
|
|
192
|
+
"decisions": _rel(path, docs / "Decisions.md"),
|
|
193
|
+
"archive": _rel(path, docs / "Archive.md"),
|
|
194
|
+
"board": _rel(path, docs / "Board.md"),
|
|
195
|
+
"worklog": _rel(path, path.parent / F_WORKLOG),
|
|
196
|
+
"inbox": _rel(path, path.parent / F_INBOX),
|
|
197
|
+
"Role": pascal(role),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def criteria_path(root: Path) -> Path:
|
|
202
|
+
return root / F_CRITERIA
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def tests_path(root: Path) -> Path:
|
|
206
|
+
return root / F_TESTS
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def testplan_path(root: Path) -> Path:
|
|
210
|
+
return root / F_TESTPLAN
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def reviews_path(root: Path) -> Path:
|
|
214
|
+
return root / F_REVIEWS
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def disputes_path(root: Path) -> Path:
|
|
218
|
+
return root / F_DISPUTES
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def skills_path(root: Path) -> Path:
|
|
222
|
+
return root / F_SKILLS
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def task_file(root: Path, agent: str) -> Path:
|
|
226
|
+
"""The role's shared queue. Any instance of the role may claim from it."""
|
|
227
|
+
role = resolve_role(agent)
|
|
228
|
+
d = role_dir(root, role)
|
|
229
|
+
if not d.is_dir() and not agents_dir(root).is_dir():
|
|
230
|
+
die("no workspace here: run `teamwork.py init` first")
|
|
231
|
+
if agents_dir(root).is_dir() and not d.is_dir():
|
|
232
|
+
known = list_roles(root)
|
|
233
|
+
if role not in known:
|
|
234
|
+
die(
|
|
235
|
+
f"no '{role}' role in this team. Known roles: "
|
|
236
|
+
+ (", ".join(known) if known else "(none)")
|
|
237
|
+
)
|
|
238
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
return d / F_TASKS
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def skills_dir(root: Path) -> Path:
|
|
243
|
+
"""Where team skills are written: the work tree this agent is actually in."""
|
|
244
|
+
return work_repo(root).joinpath(*SKILLS_DIR)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def skill_file(root: Path, rel: str) -> Path:
|
|
248
|
+
"""Locate a registered skill file from either the work tree or the project."""
|
|
249
|
+
for base in (work_repo(root), project_of(root)):
|
|
250
|
+
p = base / rel
|
|
251
|
+
if p.exists():
|
|
252
|
+
return p
|
|
253
|
+
return work_repo(root) / rel
|