@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +368 -3
  2. package/README.md +80 -26
  3. package/package.json +1 -1
  4. package/skills/teamwork/AGENTS.md +57 -2
  5. package/skills/teamwork/SKILL.md +133 -27
  6. package/skills/teamwork/references/Adapters.md +4 -0
  7. package/skills/teamwork/references/Audit.md +3 -1
  8. package/skills/teamwork/references/Concurrency.md +22 -7
  9. package/skills/teamwork/references/Coordination.md +203 -0
  10. package/skills/teamwork/references/Dissent.md +10 -1
  11. package/skills/teamwork/references/Docs.md +3 -1
  12. package/skills/teamwork/references/Engineering.md +47 -8
  13. package/skills/teamwork/references/Evidence.md +18 -1
  14. package/skills/teamwork/references/Remotes.md +29 -8
  15. package/skills/teamwork/references/Roles.md +22 -4
  16. package/skills/teamwork/references/State.md +96 -12
  17. package/skills/teamwork/references/Testing.md +3 -1
  18. package/skills/teamwork/roles/ceo.md +50 -3
  19. package/skills/teamwork/roles/developer.md +8 -0
  20. package/skills/teamwork/roles/product-manager.md +4 -0
  21. package/skills/teamwork/roles/reviewer.md +97 -12
  22. package/skills/teamwork/scripts/check_release_consistency.py +1805 -0
  23. package/skills/teamwork/scripts/privacy_scan.py +196 -82
  24. package/skills/teamwork/src/teamwork/bundle.py +0 -7
  25. package/skills/teamwork/src/teamwork/config.py +53 -2
  26. package/skills/teamwork/src/teamwork/engine.py +2722 -428
  27. package/skills/teamwork/src/teamwork/gitutil.py +148 -10
  28. package/skills/teamwork/src/teamwork/harness.py +753 -0
  29. package/skills/teamwork/src/teamwork/hooks.py +400 -0
  30. package/skills/teamwork/src/teamwork/layout.py +10 -4
  31. package/skills/teamwork/src/teamwork/portable.py +6 -1
  32. package/skills/teamwork/src/teamwork/records.py +20 -0
  33. package/skills/teamwork/src/teamwork/resources.py +41 -7
  34. package/skills/teamwork/src/teamwork/version.py +1 -1
  35. 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
- result = {"available": False, "installed": False, "patterns_added": 0}
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) >= 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
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: