@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
|
@@ -48,11 +48,13 @@ Design notes
|
|
|
48
48
|
* Stdlib only, no network, no third-party import.
|
|
49
49
|
* The scanner is itself published, so it must not become the leak it detects.
|
|
50
50
|
Private domains are therefore not stored as plain text (they are reconstructed
|
|
51
|
-
from code points at runtime) and matched
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
from code points at runtime) and a matched value never reaches a log in any
|
|
52
|
+
form: the report names the rule (``<redacted-rule>``) and no more, so a failing
|
|
53
|
+
CI log cannot republish the secret, not even a prefix of it. The raw value is
|
|
54
|
+
held outside the report record (see ``Finding``), so no reporting sink can
|
|
55
|
+
serialise it by accident. ``--reveal`` prints a stable fingerprint (SHA-256
|
|
56
|
+
prefix) instead and ``--reveal-to PATH`` exports the raw values to a ``0600``
|
|
57
|
+
file for local remediation only.
|
|
56
58
|
* The allowlist file (``--allow``) holds one regular expression per line with an
|
|
57
59
|
optional ``# reason`` suffix. A finding is exempt when its matched text matches
|
|
58
60
|
any allowlist expression. Keep it minimal and justify each entry.
|
|
@@ -63,6 +65,11 @@ Unmasked values are **never** written to stdout or stderr. A log line is a
|
|
|
63
65
|
broadcast sink - CI logs are public, terminals are recorded, and ``tee``ed
|
|
64
66
|
output outlives the session - so the scanner has no flag that prints a secret.
|
|
65
67
|
|
|
68
|
+
* The report line for a finding is
|
|
69
|
+
``<location>: [<severity>/<rule>] <redacted-<rule>>``: it says which rule fired
|
|
70
|
+
where, and deliberately no character of the value - a partly masked preview
|
|
71
|
+
still publishes the first and last characters of a credential to every reader
|
|
72
|
+
of the log, which is the disclosure this scanner exists to prevent.
|
|
66
73
|
* ``--reveal`` adds ``(sha256:<12 hex>)`` to each finding line. The fingerprint
|
|
67
74
|
is a stable, unsalted SHA-256 prefix: it identifies which finding is which and
|
|
68
75
|
lets an operator confirm a rotated value, but it cannot be used as the
|
|
@@ -91,7 +98,7 @@ import sys
|
|
|
91
98
|
from collections import Counter
|
|
92
99
|
from pathlib import Path
|
|
93
100
|
|
|
94
|
-
SCANNER_VERSION = "1.
|
|
101
|
+
SCANNER_VERSION = "1.4.0"
|
|
95
102
|
|
|
96
103
|
# --------------------------------------------------------------------------- #
|
|
97
104
|
# Neutral identity / allowlisted email domains
|
|
@@ -259,6 +266,31 @@ _CODE_KEYWORDS = {
|
|
|
259
266
|
|
|
260
267
|
_RE_DOTTED_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$")
|
|
261
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
|
+
|
|
262
294
|
|
|
263
295
|
def _looks_like_secret_value(value: str) -> bool:
|
|
264
296
|
if value.startswith(("re.", "os.", "self.", "match.", "str(", "Path(")):
|
|
@@ -465,6 +497,76 @@ def _shannon_entropy(value: str) -> float:
|
|
|
465
497
|
return -sum((n / length) * math.log2(n / length) for n in counts.values())
|
|
466
498
|
|
|
467
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
|
+
|
|
468
570
|
def _looks_like_secret(value: str, line: str = "", index: int = 0) -> bool:
|
|
469
571
|
"""Filter out hashes, identifiers, URL paths and natural words."""
|
|
470
572
|
if len(value) < 32:
|
|
@@ -478,6 +580,11 @@ def _looks_like_secret(value: str, line: str = "", index: int = 0) -> bool:
|
|
|
478
580
|
between = line[scheme + 3 : index]
|
|
479
581
|
if not re.search(r"[\s\"']", between):
|
|
480
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
|
|
481
588
|
has_digit = any(c.isdigit() for c in value)
|
|
482
589
|
has_upper = any(c.isupper() for c in value)
|
|
483
590
|
has_lower = any(c.islower() for c in value)
|
|
@@ -511,14 +618,6 @@ def _email_allowed(value: str) -> bool:
|
|
|
511
618
|
return any(domain.endswith(suffix) for suffix in _ALLOWED_EMAIL_SUFFIXES)
|
|
512
619
|
|
|
513
620
|
|
|
514
|
-
def _mask(value: str) -> str:
|
|
515
|
-
if len(value) <= 4:
|
|
516
|
-
return "*" * len(value)
|
|
517
|
-
if len(value) <= 10:
|
|
518
|
-
return value[0] + "*" * (len(value) - 2) + value[-1]
|
|
519
|
-
return value[:3] + "*" * (len(value) - 6) + value[-3:]
|
|
520
|
-
|
|
521
|
-
|
|
522
621
|
def _fingerprint(value: str, length: int = 12) -> str:
|
|
523
622
|
"""A stable, non-usable identifier for a matched value.
|
|
524
623
|
|
|
@@ -562,43 +661,49 @@ def write_reveal_file(path, pairs):
|
|
|
562
661
|
return written
|
|
563
662
|
|
|
564
663
|
|
|
565
|
-
def _clip(line: str, value: str, width: int = 160) -> str:
|
|
566
|
-
"""A short, single-line context snippet around the match."""
|
|
567
|
-
index = line.find(value)
|
|
568
|
-
if index < 0:
|
|
569
|
-
return line[:width]
|
|
570
|
-
start = max(0, index - 40)
|
|
571
|
-
end = min(len(line), index + len(value) + 40)
|
|
572
|
-
snippet = line[start:end].strip()
|
|
573
|
-
if start > 0:
|
|
574
|
-
snippet = "..." + snippet
|
|
575
|
-
if end < len(line):
|
|
576
|
-
snippet = snippet + "..."
|
|
577
|
-
return snippet[:width]
|
|
578
|
-
|
|
579
|
-
|
|
580
664
|
# --------------------------------------------------------------------------- #
|
|
581
665
|
# Findings
|
|
582
666
|
# --------------------------------------------------------------------------- #
|
|
583
667
|
|
|
584
668
|
|
|
669
|
+
class Finding(dict):
|
|
670
|
+
"""One report row, with the raw matched value held outside the mapping.
|
|
671
|
+
|
|
672
|
+
Every reporting sink serialises *items*: ``json.dumps(report)`` walks the
|
|
673
|
+
dict, the text report reads ``evidence``, and both end up in a CI log. A
|
|
674
|
+
value kept as an attribute therefore cannot be published by any of them, so
|
|
675
|
+
"the log never carries a secret" is a property of the shape rather than a
|
|
676
|
+
promise held by one ``pop`` before one ``print``. Everything that needs the
|
|
677
|
+
value itself - ``--reveal``, ``--reveal-to`` and the archive mask - reads
|
|
678
|
+
``.raw`` explicitly, which is greppable and reviewable in one place each.
|
|
679
|
+
"""
|
|
680
|
+
|
|
681
|
+
__slots__ = ("raw",)
|
|
682
|
+
|
|
683
|
+
def __init__(self, items, raw=""):
|
|
684
|
+
super().__init__(items)
|
|
685
|
+
self.raw = raw
|
|
686
|
+
|
|
687
|
+
|
|
585
688
|
def make_finding(rule_id, severity, path, line_no, value, line, source, commit=None):
|
|
586
|
-
|
|
587
|
-
#
|
|
588
|
-
#
|
|
589
|
-
|
|
590
|
-
return
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
689
|
+
# Not one character of the value, and never the surrounding line: the report
|
|
690
|
+
# names the rule the way the archive mask does, because a partly masked
|
|
691
|
+
# preview still hands the first and last characters of a credential to
|
|
692
|
+
# everyone who can read the log.
|
|
693
|
+
return Finding(
|
|
694
|
+
{
|
|
695
|
+
"rule": rule_id,
|
|
696
|
+
"severity": severity,
|
|
697
|
+
"path": path,
|
|
698
|
+
"line": line_no,
|
|
699
|
+
"source": source,
|
|
700
|
+
"commit": commit,
|
|
701
|
+
"location": location(path, line_no, commit),
|
|
702
|
+
"evidence": f"<redacted-{rule_id}>",
|
|
703
|
+
"context": "",
|
|
704
|
+
},
|
|
705
|
+
value,
|
|
706
|
+
)
|
|
602
707
|
|
|
603
708
|
|
|
604
709
|
def location(path, line_no, commit=None):
|
|
@@ -664,7 +769,8 @@ def scan_lines(text, path, source, rules, allow, commit=None):
|
|
|
664
769
|
# A quoted passphrase may legitimately contain spaces.
|
|
665
770
|
if not re.search(r"\s", secret) and not _looks_like_secret_value(secret):
|
|
666
771
|
continue
|
|
667
|
-
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.
|
|
668
774
|
continue
|
|
669
775
|
value = secret
|
|
670
776
|
if rule["id"] == "high-entropy-secret":
|
|
@@ -698,7 +804,9 @@ def scan_lines(text, path, source, rules, allow, commit=None):
|
|
|
698
804
|
if match:
|
|
699
805
|
candidate = match.group(1) or match.group(2)
|
|
700
806
|
if not _PLACEHOLDERS.match(candidate) and (
|
|
701
|
-
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)
|
|
702
810
|
):
|
|
703
811
|
credential = candidate
|
|
704
812
|
if credential and not any(p.search(credential) for p in allow):
|
|
@@ -861,18 +969,21 @@ def config_path_finding(rel, source, allow=(), commit=None):
|
|
|
861
969
|
if any(pattern.search(rel) for pattern in allow):
|
|
862
970
|
return None
|
|
863
971
|
local_state = reason.startswith("local state")
|
|
864
|
-
return
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
972
|
+
return Finding(
|
|
973
|
+
{
|
|
974
|
+
"rule": "local-state" if local_state else "local-config",
|
|
975
|
+
"severity": "high" if local_state else "critical",
|
|
976
|
+
"path": rel,
|
|
977
|
+
"line": 0,
|
|
978
|
+
"source": source,
|
|
979
|
+
"commit": commit,
|
|
980
|
+
"location": rel,
|
|
981
|
+
# A path is the finding, not a credential: naming it is the report.
|
|
982
|
+
"evidence": rel,
|
|
983
|
+
"context": reason,
|
|
984
|
+
},
|
|
985
|
+
rel,
|
|
986
|
+
)
|
|
876
987
|
|
|
877
988
|
|
|
878
989
|
def scan_tree(root, allow, stats=None):
|
|
@@ -888,18 +999,20 @@ def scan_tree(root, allow, stats=None):
|
|
|
888
999
|
data = full.read_bytes()
|
|
889
1000
|
except OSError as exc:
|
|
890
1001
|
findings.append(
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1002
|
+
Finding(
|
|
1003
|
+
{
|
|
1004
|
+
"rule": "unreadable-tracked-file",
|
|
1005
|
+
"severity": "medium",
|
|
1006
|
+
"path": rel,
|
|
1007
|
+
"line": 0,
|
|
1008
|
+
"source": "tree",
|
|
1009
|
+
"commit": None,
|
|
1010
|
+
"location": rel,
|
|
1011
|
+
"evidence": rel,
|
|
1012
|
+
"context": f"tracked file could not be read: {exc}",
|
|
1013
|
+
},
|
|
1014
|
+
rel,
|
|
1015
|
+
)
|
|
903
1016
|
)
|
|
904
1017
|
continue
|
|
905
1018
|
findings.extend(_scan_bytes(data, rel, "tree", allow))
|
|
@@ -1016,7 +1129,7 @@ def dedupe(findings):
|
|
|
1016
1129
|
seen = set()
|
|
1017
1130
|
unique = []
|
|
1018
1131
|
for finding in findings:
|
|
1019
|
-
key = (finding["rule"], finding["location"], finding
|
|
1132
|
+
key = (finding["rule"], finding["location"], finding.raw, finding.get("commit") or "")
|
|
1020
1133
|
if key in seen:
|
|
1021
1134
|
continue
|
|
1022
1135
|
seen.add(key)
|
|
@@ -1103,13 +1216,12 @@ def main(argv=None):
|
|
|
1103
1216
|
reveal_path = args.reveal_to
|
|
1104
1217
|
reveal_values = []
|
|
1105
1218
|
for finding in findings:
|
|
1106
|
-
|
|
1107
|
-
if match is None:
|
|
1219
|
+
if not finding.raw:
|
|
1108
1220
|
continue
|
|
1109
1221
|
if args.reveal:
|
|
1110
|
-
finding["fingerprint"] = _fingerprint(
|
|
1222
|
+
finding["fingerprint"] = _fingerprint(finding.raw)
|
|
1111
1223
|
if reveal_path:
|
|
1112
|
-
reveal_values.append((finding,
|
|
1224
|
+
reveal_values.append((finding, finding.raw))
|
|
1113
1225
|
|
|
1114
1226
|
reveal_written = 0
|
|
1115
1227
|
if reveal_path:
|
|
@@ -1151,16 +1263,18 @@ def main(argv=None):
|
|
|
1151
1263
|
else:
|
|
1152
1264
|
if not args.quiet:
|
|
1153
1265
|
for finding in findings:
|
|
1154
|
-
# The
|
|
1155
|
-
# sink. --reveal only adds
|
|
1266
|
+
# The value is never printed in any form: stdout is a broadcast
|
|
1267
|
+
# sink, so the report names the rule instead. --reveal only adds
|
|
1268
|
+
# a non-usable fingerprint.
|
|
1156
1269
|
shown = finding["evidence"]
|
|
1157
1270
|
fingerprint = finding.get("fingerprint")
|
|
1158
1271
|
if fingerprint:
|
|
1159
1272
|
shown = f"{shown} ({fingerprint})"
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1273
|
+
line = f"{finding['location']}: [{finding['severity']}/{finding['rule']}] {shown}"
|
|
1274
|
+
detail = finding.get("detail", "")
|
|
1275
|
+
if detail:
|
|
1276
|
+
line = f"{line} {detail}"
|
|
1277
|
+
print(line)
|
|
1164
1278
|
state = "FAIL" if findings else "PASS"
|
|
1165
1279
|
print(f"privacy_scan: {state} - {len(findings)} finding(s) in {', '.join(modes)}")
|
|
1166
1280
|
return 1 if findings else 0
|
|
@@ -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}
|