@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +16 -0
  4. package/README.md +462 -2
  5. package/SECURITY.md +64 -0
  6. package/package.json +71 -4
  7. package/prompts/teamwork-pulse.md +12 -0
  8. package/prompts/teamwork-review.md +16 -0
  9. package/skills/teamwork/AGENTS.md +462 -0
  10. package/skills/teamwork/SKILL.md +795 -0
  11. package/skills/teamwork/references/Adapters.md +112 -0
  12. package/skills/teamwork/references/Audit.md +120 -0
  13. package/skills/teamwork/references/Concurrency.md +160 -0
  14. package/skills/teamwork/references/Dissent.md +85 -0
  15. package/skills/teamwork/references/Docs.md +74 -0
  16. package/skills/teamwork/references/Engineering.md +410 -0
  17. package/skills/teamwork/references/Evidence.md +140 -0
  18. package/skills/teamwork/references/Remotes.md +144 -0
  19. package/skills/teamwork/references/Roles.md +201 -0
  20. package/skills/teamwork/references/State.md +64 -0
  21. package/skills/teamwork/references/Testing.md +144 -0
  22. package/skills/teamwork/roles/architect.md +153 -0
  23. package/skills/teamwork/roles/ceo.md +218 -0
  24. package/skills/teamwork/roles/cfo.md +150 -0
  25. package/skills/teamwork/roles/coo.md +147 -0
  26. package/skills/teamwork/roles/cto.md +162 -0
  27. package/skills/teamwork/roles/data-engineer.md +120 -0
  28. package/skills/teamwork/roles/designer.md +349 -0
  29. package/skills/teamwork/roles/developer.md +166 -0
  30. package/skills/teamwork/roles/product-manager.md +151 -0
  31. package/skills/teamwork/roles/project-manager.md +159 -0
  32. package/skills/teamwork/roles/release-engineer.md +245 -0
  33. package/skills/teamwork/roles/reviewer.md +254 -0
  34. package/skills/teamwork/roles/security-auditor.md +157 -0
  35. package/skills/teamwork/roles/technical-writer.md +121 -0
  36. package/skills/teamwork/roles/tester.md +180 -0
  37. package/skills/teamwork/scripts/build_pi_package.py +200 -0
  38. package/skills/teamwork/scripts/privacy_scan.py +1185 -0
  39. package/skills/teamwork/scripts/sync_skill.py +108 -0
  40. package/skills/teamwork/scripts/teamwork.py +28 -0
  41. package/skills/teamwork/scripts/verify_release.py +502 -0
  42. package/skills/teamwork/src/teamwork/__init__.py +18 -0
  43. package/skills/teamwork/src/teamwork/__main__.py +10 -0
  44. package/skills/teamwork/src/teamwork/bundle.py +157 -0
  45. package/skills/teamwork/src/teamwork/cli.py +16 -0
  46. package/skills/teamwork/src/teamwork/config.py +396 -0
  47. package/skills/teamwork/src/teamwork/engine.py +10119 -0
  48. package/skills/teamwork/src/teamwork/gitutil.py +172 -0
  49. package/skills/teamwork/src/teamwork/imaging.py +124 -0
  50. package/skills/teamwork/src/teamwork/io.py +144 -0
  51. package/skills/teamwork/src/teamwork/layout.py +253 -0
  52. package/skills/teamwork/src/teamwork/names.py +191 -0
  53. package/skills/teamwork/src/teamwork/portable.py +79 -0
  54. package/skills/teamwork/src/teamwork/records.py +1220 -0
  55. package/skills/teamwork/src/teamwork/resources.py +120 -0
  56. package/skills/teamwork/src/teamwork/version.py +13 -0
  57. package/skills/teamwork/templates/AGENTS.md +460 -0
  58. package/skills/teamwork/templates/Docs/Architecture.md +40 -0
  59. package/skills/teamwork/templates/Docs/Archive.md +8 -0
  60. package/skills/teamwork/templates/Docs/Board.md +28 -0
  61. package/skills/teamwork/templates/Docs/Costs.md +30 -0
  62. package/skills/teamwork/templates/Docs/Decisions.md +26 -0
  63. package/skills/teamwork/templates/Docs/Design.md +106 -0
  64. package/skills/teamwork/templates/Docs/Plan.md +24 -0
  65. package/skills/teamwork/templates/Docs/Project.md +40 -0
  66. package/skills/teamwork/templates/Docs/Release.md +43 -0
  67. package/skills/teamwork/templates/Docs/Research.md +36 -0
  68. package/skills/teamwork/templates/Docs/Risks.md +18 -0
  69. package/skills/teamwork/templates/TeamMap.md +100 -0
  70. 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