@nidvue/teamwork 1.1.0 → 1.1.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 CHANGED
@@ -9,7 +9,63 @@ All notable changes to this project are documented in this file.
9
9
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
10
10
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
- ## [Unreleased](https://github.com/Nidvue/Teamwork/compare/v1.0.2...HEAD)
12
+ ## [Unreleased](https://github.com/Nidvue/Teamwork/compare/v1.1.2...HEAD)
13
+
14
+ ## [1.1.2](https://github.com/Nidvue/Teamwork/compare/v1.1.1...v1.1.2) - 2026-10-20
15
+
16
+ ### Fixed
17
+
18
+ - **The skill now declares its interpreter.** `SKILL.md` carried no
19
+ `compatibility` field, so a host without python3 loaded the skill and only found
20
+ out at the first `teamwork.py` call. It now states
21
+ `compatibility: python3 3.9 or newer on PATH - the engine is stdlib-only, so
22
+ there is no pip install and no venv; git is required for every branch, commit
23
+ and PR operation`, and
24
+ `tests/unit/test_skill_contracts.py::test_the_skill_declares_the_agent_skills_contract`
25
+ fails when the field is missing or advertises an interpreter `pyproject.toml`
26
+ does not support.
27
+ - **A self-contradictory review rule.** The re-submission bullet read "there is no
28
+ round limit, but at fail, fix, re-check, re-submit, re-review until it passes" -
29
+ a truncated sentence that contradicted the 3-failure dispute rule stated 300
30
+ lines above it. It now says failures are counted and at 3 the Reviewer opens a
31
+ dispute, which is what the tribunal paragraph, `AGENTS.md` and
32
+ `references/Dissent.md` all say.
33
+ - **Nine tests were unreachable on NixOS and Guix.**
34
+ `tests/unit/test_skill_refresh.py` ran every `git` call with a hard-coded
35
+ `PATH=/usr/bin:/bin:/usr/local/bin`, where git does not exist on those
36
+ distributions, so the nine fixtures errored instead of testing the refresh path.
37
+ The ambient `PATH` is now prepended and the standard directories are kept as a
38
+ floor. On this machine: 9 errors -> 0.
39
+
40
+ ## [1.1.1](https://github.com/Nidvue/Teamwork/compare/v1.1.0...v1.1.1) - 2026-10-20
41
+
42
+ ### Fixed
43
+
44
+ - **npm package republished as 1.1.1**: the first `npm publish` of
45
+ `@nidvue/teamwork@1.1.0` exited 0 but only *staged* the version on the registry
46
+ (`dist-tags.latest` stayed at the `0.0.0-stage` placeholder, the packument never
47
+ appeared, and `npm stage list` returned no stage-id to reject). A second publish
48
+ of that version is refused with `E409 Cannot publish over previously staged
49
+ version`. This is the phantom-staged-publish registry bug tracked as
50
+ [npm/cli#9889](https://github.com/npm/cli/issues/9889); the version number cannot
51
+ be reused, so the package ships as 1.1.1. No code, skill or protocol change -
52
+ the only differences from the 1.1.0 build are the `version` field and this entry.
53
+
54
+ ### Security
55
+
56
+ - **The privacy scanner publishes no character of a matched value**: a finding
57
+ line now names the rule - `<location>: [<severity>/<rule>] <redacted-<rule>>` -
58
+ instead of a partly masked preview (`ghp********************************QQQ`
59
+ used to hand the first and last three characters of a credential to every reader
60
+ of a CI log, which is the disclosure the scanner exists to prevent). The raw
61
+ value is no longer an item of the finding record at all: it is held on the
62
+ `Finding` object as `.raw`, so no reporting sink - the text line, `--json`, the
63
+ archive mask - can serialise it by accident. `--reveal` (stable fingerprint) and
64
+ `--reveal-to` (`0600` file) are unchanged, and the `--json` evidence is
65
+ rule-named too. This closes the two open `py/clear-text-logging-sensitive-data`
66
+ code-scanning alerts on `scripts/privacy_scan.py` by removing the dataflow
67
+ rather than by a dismissal or a query exclusion, and `SCANNER_VERSION` moves to
68
+ `1.4.0`. Dead code that took a raw value (`_clip`) is gone.
13
69
 
14
70
  ## [1.1.0](https://github.com/Nidvue/Teamwork/releases/tag/v1.1.0) - 2026-10-20
15
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nidvue/teamwork",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Nidvue Teamwork - an autonomous, self-governing agent company for pi: CEO-led decomposition, parallel lanes, two-stage review, hash-chained audit ledger and layered role-owned tests. Python 3.9+, stdlib only.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: teamwork
3
3
  description: "Nidvue Teamwork - run a user prompt to completion with an autonomous multi-agent company: CEO, CTO, COO, CFO, Architect, Product Manager, Project Manager, Designer, Release Engineer, Technical Writer, Data Engineer, Developer(s), Tester(s), Reviewer and Security Auditor. Use when the user invokes /teamwork, asks for a team of sub-agents with roles, wants an org chart of agents that plans, builds, tests and iterates until the goal is met, or wants long-running parallel work with per-agent memory. The user talks only to the CEO; every other agent communicates internally."
4
+ compatibility: "python3 3.9 or newer on PATH - the engine is stdlib-only, so there is no pip install and no venv; git is required for every branch, commit and PR operation."
4
5
  ---
5
6
 
6
7
  # Nidvue Teamwork - `teamwork` skill
@@ -696,8 +697,9 @@ Mechanical enforcement:
696
697
  - a `review --result pass` appends the accepted result to
697
698
  `docs/Team/Archive.md` (what, who, verified how, which revision) - the team's
698
699
  evidence ledger. Read it with `teamwork.py archive [--agent X]`.
699
- - a re-submitted fix is reviewed from scratch; there is no round limit, but at
700
- fail, fix, re-check, re-submit, re-review until it passes.
700
+ - a re-submitted fix is reviewed from scratch - fail, fix, self-check, re-submit,
701
+ re-review until it passes - and the failures are counted, so at 3 the Reviewer
702
+ opens a dispute and the tribunal rules instead of the loop spinning.
701
703
  - if a finding turns into a design disagreement, use the dispute protocol.
702
704
 
703
705
  This applies to code, unit tests, integration/E2E tests, documentation, design
@@ -118,7 +118,10 @@ Security), an open `error`/`critical`/`high` alert on the default branch is a
118
118
  known defect **in the shipped revision**: the code is already public, so the
119
119
  alert is a release blocker, not a backlog item. Two measured examples on this
120
120
  repository's own remote, both `py/clear-text-logging-sensitive-data` on
121
- `scripts/privacy_scan.py`, both `error`, both on `main` with no task and no owner.
121
+ `scripts/privacy_scan.py`, both `error`, both on `main` with no task and no owner -
122
+ and both only closed when the value stopped reaching the sink at all: the raw
123
+ value left the finding record, and the report named the rule instead of printing a
124
+ partly masked preview.
122
125
 
123
126
  The Release Engineer owns the gate:
124
127
 
@@ -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
@@ -511,14 +518,6 @@ def _email_allowed(value: str) -> bool:
511
518
  return any(domain.endswith(suffix) for suffix in _ALLOWED_EMAIL_SUFFIXES)
512
519
 
513
520
 
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
521
  def _fingerprint(value: str, length: int = 12) -> str:
523
522
  """A stable, non-usable identifier for a matched value.
524
523
 
@@ -562,43 +561,49 @@ def write_reveal_file(path, pairs):
562
561
  return written
563
562
 
564
563
 
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
564
  # --------------------------------------------------------------------------- #
581
565
  # Findings
582
566
  # --------------------------------------------------------------------------- #
583
567
 
584
568
 
569
+ class Finding(dict):
570
+ """One report row, with the raw matched value held outside the mapping.
571
+
572
+ Every reporting sink serialises *items*: ``json.dumps(report)`` walks the
573
+ dict, the text report reads ``evidence``, and both end up in a CI log. A
574
+ value kept as an attribute therefore cannot be published by any of them, so
575
+ "the log never carries a secret" is a property of the shape rather than a
576
+ promise held by one ``pop`` before one ``print``. Everything that needs the
577
+ value itself - ``--reveal``, ``--reveal-to`` and the archive mask - reads
578
+ ``.raw`` explicitly, which is greppable and reviewable in one place each.
579
+ """
580
+
581
+ __slots__ = ("raw",)
582
+
583
+ def __init__(self, items, raw=""):
584
+ super().__init__(items)
585
+ self.raw = raw
586
+
587
+
585
588
  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
- }
589
+ # Not one character of the value, and never the surrounding line: the report
590
+ # names the rule the way the archive mask does, because a partly masked
591
+ # preview still hands the first and last characters of a credential to
592
+ # everyone who can read the log.
593
+ return Finding(
594
+ {
595
+ "rule": rule_id,
596
+ "severity": severity,
597
+ "path": path,
598
+ "line": line_no,
599
+ "source": source,
600
+ "commit": commit,
601
+ "location": location(path, line_no, commit),
602
+ "evidence": f"<redacted-{rule_id}>",
603
+ "context": "",
604
+ },
605
+ value,
606
+ )
602
607
 
603
608
 
604
609
  def location(path, line_no, commit=None):
@@ -861,18 +866,21 @@ def config_path_finding(rel, source, allow=(), commit=None):
861
866
  if any(pattern.search(rel) for pattern in allow):
862
867
  return None
863
868
  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
- }
869
+ return Finding(
870
+ {
871
+ "rule": "local-state" if local_state else "local-config",
872
+ "severity": "high" if local_state else "critical",
873
+ "path": rel,
874
+ "line": 0,
875
+ "source": source,
876
+ "commit": commit,
877
+ "location": rel,
878
+ # A path is the finding, not a credential: naming it is the report.
879
+ "evidence": rel,
880
+ "context": reason,
881
+ },
882
+ rel,
883
+ )
876
884
 
877
885
 
878
886
  def scan_tree(root, allow, stats=None):
@@ -888,18 +896,20 @@ def scan_tree(root, allow, stats=None):
888
896
  data = full.read_bytes()
889
897
  except OSError as exc:
890
898
  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
- }
899
+ Finding(
900
+ {
901
+ "rule": "unreadable-tracked-file",
902
+ "severity": "medium",
903
+ "path": rel,
904
+ "line": 0,
905
+ "source": "tree",
906
+ "commit": None,
907
+ "location": rel,
908
+ "evidence": rel,
909
+ "context": f"tracked file could not be read: {exc}",
910
+ },
911
+ rel,
912
+ )
903
913
  )
904
914
  continue
905
915
  findings.extend(_scan_bytes(data, rel, "tree", allow))
@@ -1016,7 +1026,7 @@ def dedupe(findings):
1016
1026
  seen = set()
1017
1027
  unique = []
1018
1028
  for finding in findings:
1019
- key = (finding["rule"], finding["location"], finding["match"], finding.get("commit") or "")
1029
+ key = (finding["rule"], finding["location"], finding.raw, finding.get("commit") or "")
1020
1030
  if key in seen:
1021
1031
  continue
1022
1032
  seen.add(key)
@@ -1103,13 +1113,12 @@ def main(argv=None):
1103
1113
  reveal_path = args.reveal_to
1104
1114
  reveal_values = []
1105
1115
  for finding in findings:
1106
- match = finding.pop("match", None)
1107
- if match is None:
1116
+ if not finding.raw:
1108
1117
  continue
1109
1118
  if args.reveal:
1110
- finding["fingerprint"] = _fingerprint(match)
1119
+ finding["fingerprint"] = _fingerprint(finding.raw)
1111
1120
  if reveal_path:
1112
- reveal_values.append((finding, match))
1121
+ reveal_values.append((finding, finding.raw))
1113
1122
 
1114
1123
  reveal_written = 0
1115
1124
  if reveal_path:
@@ -1151,16 +1160,18 @@ def main(argv=None):
1151
1160
  else:
1152
1161
  if not args.quiet:
1153
1162
  for finding in findings:
1154
- # The unmasked value is never printed: stdout is a broadcast
1155
- # sink. --reveal only adds a non-usable fingerprint.
1163
+ # The value is never printed in any form: stdout is a broadcast
1164
+ # sink, so the report names the rule instead. --reveal only adds
1165
+ # a non-usable fingerprint.
1156
1166
  shown = finding["evidence"]
1157
1167
  fingerprint = finding.get("fingerprint")
1158
1168
  if fingerprint:
1159
1169
  shown = f"{shown} ({fingerprint})"
1160
- print(
1161
- f"{finding['location']}: [{finding['severity']}/"
1162
- f"{finding['rule']}] {shown} {finding.get('detail', '')}"
1163
- )
1170
+ line = f"{finding['location']}: [{finding['severity']}/{finding['rule']}] {shown}"
1171
+ detail = finding.get("detail", "")
1172
+ if detail:
1173
+ line = f"{line} {detail}"
1174
+ print(line)
1164
1175
  state = "FAIL" if findings else "PASS"
1165
1176
  print(f"privacy_scan: {state} - {len(findings)} finding(s) in {', '.join(modes)}")
1166
1177
  return 1 if findings else 0
@@ -3214,6 +3214,30 @@ def default_audit_range(project: Path) -> str:
3214
3214
  return "HEAD"
3215
3215
 
3216
3216
 
3217
+ def rewrite_safe_range(project: Path, rev_range: str) -> str:
3218
+ """Narrow ``A..B`` to ``B`` when ``A`` no longer exists, and say so.
3219
+
3220
+ A push event hands CI the revision the branch had before the push. When that
3221
+ push *rewrote* history - the sanctioned repair for a commit carrying a
3222
+ personal mailbox - the old revision is gone, ``git log A..B`` exits 128 with
3223
+ "Invalid revision range", and the identity audit dies over a range that can no
3224
+ longer be measured. The new tip can still be audited, so audit it, name the
3225
+ rewrite, and never pretend the unmeasurable span was clean.
3226
+ """
3227
+ if ".." not in rev_range:
3228
+ return rev_range
3229
+ base = rev_range.split("..", 1)[0].strip()
3230
+ if not base or base.startswith("-"):
3231
+ return rev_range
3232
+ if run_git(project, "cat-file", "-e", f"{base}^{{commit}}").returncode == 0:
3233
+ return rev_range
3234
+ print(
3235
+ f"# {base[:12]} is not an ancestor of this clone (history rewrite): "
3236
+ f"auditing the tip only, the span before it is unmeasurable"
3237
+ )
3238
+ return rev_range.split("..", 1)[1].strip() or "HEAD"
3239
+
3240
+
3217
3241
  def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool = False) -> int:
3218
3242
  """Fail when a commit is not authored and committed by the acting role.
3219
3243
 
@@ -3228,6 +3252,7 @@ def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool
3228
3252
  if rev_range.startswith("-"):
3229
3253
  print(f"teamwork: invalid revision range {rev_range!r}")
3230
3254
  return 2
3255
+ rev_range = rewrite_safe_range(project, rev_range)
3231
3256
  proc = run_git(
3232
3257
  project, "log", "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%P%x1f%B%x1e", rev_range
3233
3258
  )
@@ -6608,7 +6633,9 @@ def mask_secret_shapes(text: str) -> str:
6608
6633
  # placeholder, code-shape and allow filters, so a placeholder the gate accepts
6609
6634
  # (``${API_KEY}``, ``replace_me``) was rewritten in the immutable record.
6610
6635
  for finding in scanner.scan_lines(text, "", "archive", rules, ()):
6611
- value = finding.get("match") or ""
6636
+ # The raw value is a Finding attribute, never a report item, so the
6637
+ # scanner cannot publish it and this mask reads it explicitly.
6638
+ value = finding.raw or ""
6612
6639
  if not value:
6613
6640
  continue
6614
6641
  rule = finding["rule"]
@@ -6,7 +6,7 @@ that importing ``teamwork`` stays cheap.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "1.1.0"
9
+ __version__ = "1.1.2"
10
10
 
11
11
  #: Distribution name on PyPI. The name ``teamwork`` is already taken there, so
12
12
  #: the project ships as ``teamwork-agents`` while importing as ``teamwork``.