@nidvue/teamwork 1.1.1 → 1.4.2
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 +368 -3
- package/README.md +80 -26
- package/package.json +1 -1
- package/skills/teamwork/AGENTS.md +57 -2
- package/skills/teamwork/SKILL.md +133 -27
- package/skills/teamwork/references/Adapters.md +4 -0
- package/skills/teamwork/references/Audit.md +3 -1
- package/skills/teamwork/references/Concurrency.md +22 -7
- package/skills/teamwork/references/Coordination.md +203 -0
- package/skills/teamwork/references/Dissent.md +10 -1
- package/skills/teamwork/references/Docs.md +3 -1
- package/skills/teamwork/references/Engineering.md +47 -8
- package/skills/teamwork/references/Evidence.md +18 -1
- package/skills/teamwork/references/Remotes.md +29 -8
- package/skills/teamwork/references/Roles.md +22 -4
- package/skills/teamwork/references/State.md +96 -12
- package/skills/teamwork/references/Testing.md +3 -1
- package/skills/teamwork/roles/ceo.md +50 -3
- package/skills/teamwork/roles/developer.md +8 -0
- package/skills/teamwork/roles/product-manager.md +4 -0
- package/skills/teamwork/roles/reviewer.md +97 -12
- package/skills/teamwork/scripts/check_release_consistency.py +1805 -0
- package/skills/teamwork/scripts/privacy_scan.py +196 -82
- package/skills/teamwork/src/teamwork/bundle.py +0 -7
- package/skills/teamwork/src/teamwork/config.py +53 -2
- package/skills/teamwork/src/teamwork/engine.py +2722 -428
- package/skills/teamwork/src/teamwork/gitutil.py +148 -10
- package/skills/teamwork/src/teamwork/harness.py +753 -0
- package/skills/teamwork/src/teamwork/hooks.py +400 -0
- package/skills/teamwork/src/teamwork/layout.py +10 -4
- package/skills/teamwork/src/teamwork/portable.py +6 -1
- package/skills/teamwork/src/teamwork/records.py +20 -0
- package/skills/teamwork/src/teamwork/resources.py +41 -7
- package/skills/teamwork/src/teamwork/version.py +1 -1
- package/skills/teamwork/templates/AGENTS.md +57 -2
|
@@ -114,9 +114,50 @@ def branch_worktree(project: Path, branch: str):
|
|
|
114
114
|
return None
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
class HooksDirUnresolved(RuntimeError):
|
|
118
|
+
"""git could not answer ``rev-parse --git-path hooks`` for this work tree.
|
|
119
|
+
|
|
120
|
+
This exception is the *single* hooks-directory contract: it is raised here and
|
|
121
|
+
each caller chooses what a failure means for it. ``hooks.hooks_dir`` (the
|
|
122
|
+
installer) hands the message to ``die``, so the operator sees the same text as
|
|
123
|
+
before; ``harness.hooks_dir`` (a report, which must never abort a listing)
|
|
124
|
+
catches it and returns ``None``. Two divergent implementations of the same
|
|
125
|
+
``rev-parse`` made the report disagree with the installer about both resolution
|
|
126
|
+
and failure - one function, two policies, is the fix.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, project: Path, stderr: str) -> None:
|
|
130
|
+
self.project = project
|
|
131
|
+
self.stderr = stderr
|
|
132
|
+
super().__init__(f"cannot resolve the git hooks directory for {project}: {stderr}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def hooks_dir(project: Path) -> Path:
|
|
136
|
+
"""``<git-dir>/hooks``, resolved, so a linked worktree finds the shared one.
|
|
137
|
+
|
|
138
|
+
``git rev-parse --git-path hooks`` prints a path relative to ``project``; it is
|
|
139
|
+
resolved here because inside a worktree the answer is the *common* git
|
|
140
|
+
directory, which is not under ``project`` at all. Raises
|
|
141
|
+
:class:`HooksDirUnresolved` when git cannot answer - the callers decide whether
|
|
142
|
+
that is fatal (installer) or a reported absence (harness).
|
|
143
|
+
"""
|
|
144
|
+
result = run_git(project, "rev-parse", "--git-path", "hooks")
|
|
145
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
146
|
+
raise HooksDirUnresolved(project, result.stderr.strip())
|
|
147
|
+
located = Path(result.stdout.strip())
|
|
148
|
+
return (located if located.is_absolute() else project / located).resolve()
|
|
149
|
+
|
|
150
|
+
|
|
117
151
|
def ensure_lfs(project: Path, quiet: bool = False) -> dict:
|
|
118
|
-
"""Enable Git LFS locally and keep .gitattributes in sync.
|
|
119
|
-
|
|
152
|
+
"""Enable Git LFS locally and keep .gitattributes in sync.
|
|
153
|
+
|
|
154
|
+
``installed`` and ``install_error`` are the readable status the caller (and a
|
|
155
|
+
test) can act on: a failed ``git lfs install`` used to be swallowed while the
|
|
156
|
+
probe above warned, which left a repository whose ``.gitattributes`` names LFS
|
|
157
|
+
filters that git has never been told about - every large binary then committed
|
|
158
|
+
as a plain blob, silently.
|
|
159
|
+
"""
|
|
160
|
+
result = {"available": False, "installed": False, "install_error": "", "patterns_added": 0}
|
|
120
161
|
probe = subprocess.run(["git", "lfs", "version"], capture_output=True, text=True)
|
|
121
162
|
if probe.returncode != 0:
|
|
122
163
|
if not quiet:
|
|
@@ -130,6 +171,14 @@ def ensure_lfs(project: Path, quiet: bool = False) -> dict:
|
|
|
130
171
|
["git", "-C", str(project), "lfs", "install", "--local"], capture_output=True, text=True
|
|
131
172
|
)
|
|
132
173
|
result["installed"] = inst.returncode == 0
|
|
174
|
+
if not result["installed"]:
|
|
175
|
+
result["install_error"] = (inst.stderr.strip() or inst.stdout.strip())[:500]
|
|
176
|
+
if not quiet:
|
|
177
|
+
print(
|
|
178
|
+
"teamwork: WARNING `git lfs install --local` failed; large binaries will"
|
|
179
|
+
" be committed as plain blobs. Run it by hand to enable archival"
|
|
180
|
+
f" ({result['install_error'] or 'no output'})."
|
|
181
|
+
)
|
|
133
182
|
|
|
134
183
|
path = project / ".gitattributes"
|
|
135
184
|
existing = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
@@ -150,21 +199,110 @@ def ensure_lfs(project: Path, quiet: bool = False) -> dict:
|
|
|
150
199
|
return result
|
|
151
200
|
|
|
152
201
|
|
|
202
|
+
BINARY_SNIFF_BYTES = 8192
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_binary_content(path: Path) -> bool:
|
|
206
|
+
"""True when the file's CONTENT is binary, the way git decides it.
|
|
207
|
+
|
|
208
|
+
The LFS threshold is a binary threshold (Architecture 11.10): a text record
|
|
209
|
+
that grows past it is still a document, so the gates must not refuse it. The
|
|
210
|
+
test reads the bytes, never the name, because a name can be defeated - a
|
|
211
|
+
real binary renamed ``.md`` is still a binary, and a genuine binary dropped
|
|
212
|
+
into ``docs/Team/`` is still a binary. Git makes the same call from the
|
|
213
|
+
bytes (``git diff --numstat`` prints ``- -`` for a binary), so this is the
|
|
214
|
+
model the repository already trusts.
|
|
215
|
+
|
|
216
|
+
A NUL byte in the first 8 KiB is the discriminator, with one exception: a
|
|
217
|
+
UTF-16 document also contains NUL bytes, so a head that decodes as UTF-16
|
|
218
|
+
into printable text is text and stays exempt.
|
|
219
|
+
|
|
220
|
+
The exception is bounded by NUL DENSITY, and it has to be: a UTF-16 file that
|
|
221
|
+
contains NUL bytes at all is a mostly-ASCII document, because a NUL byte IS
|
|
222
|
+
the high half of an ASCII unit - so such a head is at least a quarter NULs.
|
|
223
|
+
(A CJK-only UTF-16 document carries almost no NULs, and it is already text
|
|
224
|
+
above, before this branch is reached.) Without the density bound a single
|
|
225
|
+
leading NUL followed by a run of ``x`` bytes decodes as printable CJK and a
|
|
226
|
+
real 1 MiB blob passes every LFS gate - the gate would be vacuous exactly
|
|
227
|
+
where it matters. `tests/unit/test_audit_evidence_sources.py::
|
|
228
|
+
test_a_large_binary_is_still_an_lfs_candidate` pins that case.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
with open(path, "rb") as fh:
|
|
232
|
+
head = fh.read(BINARY_SNIFF_BYTES)
|
|
233
|
+
except OSError:
|
|
234
|
+
return False
|
|
235
|
+
if not head:
|
|
236
|
+
return False
|
|
237
|
+
nul_ratio = head.count(b"\x00") / len(head)
|
|
238
|
+
if not nul_ratio:
|
|
239
|
+
return False
|
|
240
|
+
if nul_ratio < 0.25:
|
|
241
|
+
return True
|
|
242
|
+
for encoding in ("utf-16-le", "utf-16-be"):
|
|
243
|
+
try:
|
|
244
|
+
candidate = head.decode(encoding)
|
|
245
|
+
except UnicodeDecodeError:
|
|
246
|
+
continue
|
|
247
|
+
if candidate and all(ch.isprintable() or ch in "\n\r\t" for ch in candidate):
|
|
248
|
+
return False
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _attr_matches(pattern: str, rel: str, name: str) -> bool:
|
|
253
|
+
"""Whether a .gitattributes pattern covers this path."""
|
|
254
|
+
if pattern.startswith("*."):
|
|
255
|
+
return name.endswith(pattern[1:])
|
|
256
|
+
return bool(Path(rel).match(pattern))
|
|
257
|
+
|
|
258
|
+
|
|
153
259
|
def lfs_tracked(project: Path, rel: str) -> bool:
|
|
260
|
+
"""Whether a .gitattributes pattern would route this file into Git LFS.
|
|
261
|
+
|
|
262
|
+
The LAST matching line wins, as git reads it: a later ``-filter`` opt-out
|
|
263
|
+
cancels an earlier ``filter=lfs`` pattern. The opt-out matters because
|
|
264
|
+
`.gitattributes` opts the small social-preview PNG and the brand assets OUT
|
|
265
|
+
of LFS, and a check that ignored the opt-out would report them as LFS
|
|
266
|
+
files - and, for a text file, would report a misconfiguration that does not
|
|
267
|
+
exist.
|
|
268
|
+
"""
|
|
154
269
|
path = project / ".gitattributes"
|
|
155
270
|
if not path.exists():
|
|
156
271
|
return False
|
|
157
272
|
name = Path(rel).name
|
|
273
|
+
tracked = False
|
|
158
274
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
159
275
|
parts = line.split()
|
|
160
|
-
if len(parts)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
276
|
+
if len(parts) < 2:
|
|
277
|
+
continue
|
|
278
|
+
if not _attr_matches(parts[0], rel, name):
|
|
279
|
+
continue
|
|
280
|
+
if "filter=lfs" in line:
|
|
281
|
+
tracked = True
|
|
282
|
+
elif "-filter" in line:
|
|
283
|
+
tracked = False
|
|
284
|
+
return tracked
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def lfs_text_mismatch(project: Path, rel: str) -> bool:
|
|
288
|
+
"""True when a TEXT file is captured by an LFS pattern - a misconfiguration.
|
|
289
|
+
|
|
290
|
+
``.gitattributes`` routes a file into Git LFS BY NAME. A name is not
|
|
291
|
+
evidence, so a text file can be routed into LFS by accident: a record
|
|
292
|
+
written to ``assets/movie.bin`` is a 126-byte POINTER in the tree, and the
|
|
293
|
+
document is gone for every reader without an LFS client. LFS is for
|
|
294
|
+
binaries; a document in LFS is broken.
|
|
295
|
+
|
|
296
|
+
The size threshold is not the discriminator here and must not be: a text
|
|
297
|
+
file in LFS is broken at ANY size, so this check is size-independent. It
|
|
298
|
+
asks two questions of the same file - is the content text (the shared
|
|
299
|
+
``is_binary_content``) and does a pattern capture the name (the shared
|
|
300
|
+
``lfs_tracked``) - and both answers are reused by the three gates, so the
|
|
301
|
+
gates cannot disagree about which files are misconfigured.
|
|
302
|
+
"""
|
|
303
|
+
if not lfs_tracked(project, rel):
|
|
304
|
+
return False
|
|
305
|
+
return not is_binary_content(project / rel)
|
|
168
306
|
|
|
169
307
|
|
|
170
308
|
def branch_name(instance: str, task_id: str, title: str) -> str:
|