@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
@@ -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 values are **masked by default** so a
52
- failing CI log cannot republish the secret. Unmasked values are **never**
53
- printed: ``--reveal`` prints a stable fingerprint (SHA-256 prefix) instead and
54
- ``--reveal-to PATH`` exports the raw values to a ``0600`` file for local
55
- remediation only.
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.3.0"
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
- masked = _mask(value)
587
- # Never ship the surrounding line: a second credential in the same window
588
- # would be republished in plaintext (the scanner is published too).
589
- context = ""
590
- return {
591
- "rule": rule_id,
592
- "severity": severity,
593
- "path": path,
594
- "line": line_no,
595
- "source": source,
596
- "commit": commit,
597
- "location": location(path, line_no, commit),
598
- "evidence": masked,
599
- "match": value,
600
- "context": context,
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 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)
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
- "rule": "local-state" if local_state else "local-config",
866
- "severity": "high" if local_state else "critical",
867
- "path": rel,
868
- "line": 0,
869
- "source": source,
870
- "commit": commit,
871
- "location": rel,
872
- "evidence": rel,
873
- "match": rel,
874
- "context": reason,
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
- "rule": "unreadable-tracked-file",
893
- "severity": "medium",
894
- "path": rel,
895
- "line": 0,
896
- "source": "tree",
897
- "commit": None,
898
- "location": rel,
899
- "evidence": rel,
900
- "match": rel,
901
- "context": f"tracked file could not be read: {exc}",
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["match"], finding.get("commit") or "")
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
- match = finding.pop("match", None)
1107
- if match is None:
1219
+ if not finding.raw:
1108
1220
  continue
1109
1221
  if args.reveal:
1110
- finding["fingerprint"] = _fingerprint(match)
1222
+ finding["fingerprint"] = _fingerprint(finding.raw)
1111
1223
  if reveal_path:
1112
- reveal_values.append((finding, match))
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 unmasked value is never printed: stdout is a broadcast
1155
- # sink. --reveal only adds a non-usable fingerprint.
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
- print(
1161
- f"{finding['location']}: [{finding['severity']}/"
1162
- f"{finding['rule']}] {shown} {finding.get('detail', '')}"
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 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}