@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.
- package/CHANGELOG.md +326 -3
- package/README.md +80 -26
- package/package.json +1 -1
- package/skills/teamwork/AGENTS.md +57 -2
- package/skills/teamwork/SKILL.md +129 -25
- 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 +25 -7
- 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 +105 -2
- 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 +2652 -385
- 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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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}
|