@nidvue/teamwork 1.1.2 → 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 +326 -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 +129 -25
  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 +25 -7
  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 +105 -2
  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 +2652 -385
  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
@@ -266,6 +266,31 @@ _CODE_KEYWORDS = {
266
266
 
267
267
  _RE_DOTTED_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$")
268
268
 
269
+ # A bare Python name: letters, digits and underscores only, no punctuation.
270
+ _RE_PY_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
271
+
272
+ # Words that mark a name as a credential in its own right even when it is shaped
273
+ # like a code name (``my_super_secret_password``).
274
+ _SECRET_WORDS = ("secret", "password", "passwd", "passphrase", "credential")
275
+
276
+
277
+ def _is_code_name(value: str) -> bool:
278
+ """True for a bare identifier used as a value: code, not a credential.
279
+
280
+ ``token = _current_github_token()`` assigns the *result of a call*, and the
281
+ scanner only sees the name because the call's parentheses are outside the
282
+ credential charset. A name-shaped value is code when it is written the way
283
+ code is written: underscored, digit-free and not itself naming a secret.
284
+ A value that carries digits (a cloud access key, a hex blob) or that names a
285
+ secret stays a credential, so the rule is not weakened.
286
+ """
287
+ if not _RE_PY_IDENT.match(value):
288
+ return False
289
+ if "_" not in value or any(c.isdigit() for c in value):
290
+ return False
291
+ lowered = value.lower()
292
+ return not any(word in lowered for word in _SECRET_WORDS)
293
+
269
294
 
270
295
  def _looks_like_secret_value(value: str) -> bool:
271
296
  if value.startswith(("re.", "os.", "self.", "match.", "str(", "Path(")):
@@ -472,6 +497,76 @@ def _shannon_entropy(value: str) -> float:
472
497
  return -sum((n / length) * math.log2(n / length) for n in counts.values())
473
498
 
474
499
 
500
+ #: A token that may continue past the match: the high-entropy rule matches 32+
501
+ #: base64-ish characters, and a path segment may continue with a file name, a
502
+ #: second extension or another segment.
503
+ _TOKEN_CHARS = re.compile(r"[A-Za-z0-9._/+=-]+")
504
+ #: One path segment of a repository path: a name, optionally with an extension.
505
+ _PATH_SEGMENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
506
+ #: The words of a name: a CamelCase run splits into its parts, a trailing digit
507
+ #: group is its own part.
508
+ _NAME_WORDS = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z]*|[a-z]+|[0-9]+")
509
+ #: A lower-case letter followed by an upper-case one: the CamelCase mark that
510
+ #: separates a name like ``ProductManager`` from a random mix of letters.
511
+ _CAMEL_CASE = re.compile(r"[a-z][A-Z]")
512
+ #: How many words a path segment may have before it stops reading as a name.
513
+ _MAX_NAME_WORDS = 3
514
+ #: A one-letter alphabetic part means a random mix, not a name.
515
+ _MIN_NAME_WORD = 2
516
+
517
+
518
+ def _token_around(line: str, index: int, length: int) -> str:
519
+ """The whole token the match at ``index`` sits in, or ``""``."""
520
+ for match in _TOKEN_CHARS.finditer(line):
521
+ if match.start() <= index and index + length <= match.end():
522
+ return match.group(0)
523
+ return ""
524
+
525
+
526
+ def _is_path_name(token: str) -> bool:
527
+ """Does this token read as a repository path rather than as a random string?
528
+
529
+ Every segment must be a name: a word (or a few words in CamelCase) with an
530
+ optional extension. A random base64 run splits into many short fragments,
531
+ which is what tells the two apart.
532
+ """
533
+ if "/" not in token:
534
+ return False
535
+ segments = [segment for segment in token.split("/") if segment]
536
+ if not segments:
537
+ return False
538
+ for segment in segments:
539
+ if not _PATH_SEGMENT.fullmatch(segment):
540
+ return False
541
+ words = _NAME_WORDS.findall(segment.split(".")[0])
542
+ if not words or len(words) > _MAX_NAME_WORDS:
543
+ return False
544
+ if any(len(word) < _MIN_NAME_WORD and word.isalpha() for word in words):
545
+ return False
546
+ return True
547
+
548
+
549
+ def _is_path_fragment(line: str, index: int, length: int) -> bool:
550
+ """Is the match a piece of a path, rather than a value in its own right?
551
+
552
+ The high-entropy rule matches 32+ base64-ish characters, so it also matches
553
+ the first 33 characters of a longer path: ``ProductManager/Artifacts/Harness2``
554
+ is the head of ``ProductManager/Artifacts/Harness2-Brief.md``. A credential
555
+ is a whole token, so two signals together are decisive - the token must read
556
+ as a path, and it must be a NAME (CamelCase, or cut out of a longer path).
557
+ A random base64 run reads as neither: it has too many fragments to be a
558
+ name, and it is a whole token.
559
+ """
560
+ whole = _token_around(line, index, length)
561
+ if not _is_path_name(whole):
562
+ return False
563
+ if len(whole) > length:
564
+ following = whole[length]
565
+ if following.isalnum() or following in "-_":
566
+ return True
567
+ return any(_CAMEL_CASE.search(part) for part in whole.split("/") if part)
568
+
569
+
475
570
  def _looks_like_secret(value: str, line: str = "", index: int = 0) -> bool:
476
571
  """Filter out hashes, identifiers, URL paths and natural words."""
477
572
  if len(value) < 32:
@@ -485,6 +580,11 @@ def _looks_like_secret(value: str, line: str = "", index: int = 0) -> bool:
485
580
  between = line[scheme + 3 : index]
486
581
  if not re.search(r"[\s\"']", between):
487
582
  return False
583
+ # A relative path is a path, not a secret. The rule matches the first 32+
584
+ # characters of a longer path, so the match has to be checked against the
585
+ # whole token it was cut from.
586
+ if _is_path_fragment(line, index, len(value)):
587
+ return False
488
588
  has_digit = any(c.isdigit() for c in value)
489
589
  has_upper = any(c.isupper() for c in value)
490
590
  has_lower = any(c.islower() for c in value)
@@ -669,7 +769,8 @@ def scan_lines(text, path, source, rules, allow, commit=None):
669
769
  # A quoted passphrase may legitimately contain spaces.
670
770
  if not re.search(r"\s", secret) and not _looks_like_secret_value(secret):
671
771
  continue
672
- elif not _looks_like_secret_value(secret):
772
+ elif _is_code_name(secret) or not _looks_like_secret_value(secret):
773
+ # An unquoted name is a reference to a value, not the value.
673
774
  continue
674
775
  value = secret
675
776
  if rule["id"] == "high-entropy-secret":
@@ -703,7 +804,9 @@ def scan_lines(text, path, source, rules, allow, commit=None):
703
804
  if match:
704
805
  candidate = match.group(1) or match.group(2)
705
806
  if not _PLACEHOLDERS.match(candidate) and (
706
- match.group(1) is not None or _looks_like_secret_value(candidate)
807
+ match.group(1) is not None
808
+ or not _is_code_name(candidate)
809
+ and _looks_like_secret_value(candidate)
707
810
  ):
708
811
  credential = candidate
709
812
  if credential and not any(p.search(credential) for p in allow):
@@ -31,16 +31,9 @@ HOST_DEFAULTS = {
31
31
  "agents": Path("~/.agents/skills/teamwork"),
32
32
  }
33
33
 
34
- #: Marker that identifies a directory as a skill bundle rather than a random tree.
35
- MARKERS = ("SKILL.md", "roles", "references", "templates")
36
-
37
34
  _SKIP_SUFFIXES = (".pyc", ".pyo")
38
35
 
39
36
 
40
- def looks_like_bundle(path: Path) -> bool:
41
- return path.is_dir() and any((path / marker).exists() for marker in MARKERS)
42
-
43
-
44
37
  def _skip(path: Path) -> bool:
45
38
  if "__pycache__" in path.parts or any(p.endswith(".egg-info") for p in path.parts):
46
39
  return True
@@ -182,6 +182,30 @@ SLOT_TTL_MINUTES = int(os.environ.get("TEAMWORK_SLOT_TTL_MINUTES", "90"))
182
182
 
183
183
  REVIEW_FAIL_LIMIT = int(os.environ.get("TEAMWORK_REVIEW_FAIL_LIMIT", "3"))
184
184
 
185
+ # A review claim is a short work lease, not a provider slot. A lane that dies
186
+ # mid-review must not freeze the deliverable for the 90-minute slot TTL.
187
+ REVIEW_LEASE_MINUTES = int(os.environ.get("TEAMWORK_REVIEW_LEASE_MINUTES", "20"))
188
+
189
+ # A role's shared memory has ONE revising instance at a time. The role file lock
190
+ # makes a single write atomic; this lease is what holds the pen across several
191
+ # commands, so an instance can finish one coherent memory update (`memory claim`
192
+ # ... several `remember`/`forget` ... `memory release`) before a peer interleaves
193
+ # its own view of the same file. A dead holder must not freeze the role forever,
194
+ # so the lease expires and the next writer takes it over.
195
+ MEMORY_LEASE_MINUTES = int(os.environ.get("TEAMWORK_MEMORY_LEASE_MINUTES", "30"))
196
+
197
+ # A lease that outlives this long is "slow": the role's other instances may contest
198
+ # it and the arbiter (the CTO) rules. The default is half a lease, so a normal
199
+ # multi-entry update never trips it while a stuck lane is visible well before the
200
+ # lease expires and frees itself.
201
+ MEMORY_STALL_MINUTES = int(os.environ.get("TEAMWORK_MEMORY_STALL_MINUTES", "15"))
202
+
203
+ # Who rules on a contested memory lease. The CTO owns engineering standards and the
204
+ # build and lock policy, so a stuck writer is an engineering question rather than a
205
+ # dispute between peers. The arbiter folds up the reporting line when the CTO is
206
+ # unstaffed (a slim roster), and a party never rules its own lease.
207
+ MEMORY_ARBITER_ROLE = os.environ.get("TEAMWORK_MEMORY_ARBITER", "cto")
208
+
185
209
  SHORT_CAP = int(os.environ.get("TEAMWORK_SHORT_CAP", "24"))
186
210
 
187
211
  LONG_CAP = int(os.environ.get("TEAMWORK_LONG_CAP", "64"))
@@ -382,13 +406,40 @@ DEFAULT_ROSTER_TIER = "slim"
382
406
  # Roles the gates cannot do without, whichever tier is chosen.
383
407
  ROSTER_REQUIRED = ("ceo", "developer", "tester", "reviewer")
384
408
 
385
- # Instances per role for a tier: reviewers get two so one verifies and another merges.
409
+ # Instances per role for a tier: reviewers get two so the review queue fans out
410
+ # and a sampled or high-risk deliverable gets a second independent verdict.
386
411
  ROSTER_INSTANCE_COUNT = {"reviewer": 2, "developer": 1, "tester": 1}
387
412
 
388
- SETTINGS_NON_PROVIDER = ("PulseMinutes", "Criteria")
413
+ # Settings keys that are workspace policy, not provider concurrency limits: `slot`
414
+ # reads every other key as a provider cap, so a new non-provider key must be
415
+ # listed here or it becomes a phantom provider.
416
+ SETTINGS_NON_PROVIDER = (
417
+ "PulseMinutes",
418
+ "Criteria",
419
+ "AlwaysOnBudgetKb",
420
+ "ReviewLeaseMinutes",
421
+ "MemoryLeaseMinutes",
422
+ )
423
+
424
+ # The always-on context cap, per instance, in KB: SKILL.md + the managed teamwork
425
+ # block of AGENTS.md + the role charter + the role memory. `harness budget` reads
426
+ # `AlwaysOnBudgetKb` from the team Settings.json and falls back to this.
427
+ DEFAULT_ALWAYS_ON_BUDGET_KB = 120
389
428
 
390
429
  DEFAULT_BASE = "main"
391
430
 
431
+ # The two git hook stages `hooks install` writes. `status`, `uninstall` and the
432
+ # overwrite refusal all key on HOOK_MARKER, so a hook without that line is never
433
+ # touched: it belongs to someone else.
434
+ HOOK_STAGES = ("pre-commit", "pre-push")
435
+
436
+ HOOK_MARKER = "teamwork-managed hook"
437
+
438
+ # Git LFS threshold shared by the pre-commit hook and `commit --check-lfs`: one
439
+ # MiB, because "1 MB" in decimal and binary differ by 4.8% and a threshold that
440
+ # moves with the reader is not a rule (D-20261020-Architect-7).
441
+ LFS_MAX_BYTES = 1024 * 1024
442
+
392
443
  REVIEW_STATUSES = ("open", "approved", "changes-requested", "merged", "closed")
393
444
 
394
445
  PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}