@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
@@ -0,0 +1,753 @@
1
+ """The harness surface: its registry, its always-on budget and its lint.
2
+
3
+ Harness^2 names the *harness* around a frozen model as eight file-level
4
+ components, each with an activation class that says what it costs on every run.
5
+ This module does the same for this repository: the registry maps C1..C8 onto real
6
+ paths, and every byte count is measured on disk at run time, so a row cannot
7
+ drift from the tree it claims to describe.
8
+
9
+ ``harness list`` is deliberately a table, not a diagram: one row per component -
10
+ never one per file, so the table can be read against the source taxonomy row by
11
+ row - with an activation class, a measured cost and the role that pays it. C5
12
+ (subagents) is the one component that is not a file - a lane packet is assembled
13
+ per task - so its class, byte and role cells are ``-`` and its path cell says so.
14
+
15
+ The path column is the specification the bytes were measured through, not a
16
+ short name for the component: ``AGENTS.md`` would not let a reader recompute the
17
+ managed block's size from a file that also holds text outside the block, and
18
+ ``scripts/*.py`` alone would not explain a count that also sums
19
+ ``src/teamwork/*.py``. Every cell is therefore the frozen spec of Architecture
20
+ 12.2, so ``wc -c`` over what the row names reproduces the number printed beside
21
+ it.
22
+
23
+ ``harness lint`` is the whole-proposal invalidator. A skill that no always-on
24
+ file names is silently dead, a ``teamwork.py <word>`` the CLI does not implement
25
+ is a broken instruction, and a referenced path that was never shipped sends the
26
+ reader to nowhere. Each of those is a fact about the tree, so each is checked
27
+ mechanically instead of being trusted to review.
28
+
29
+ The always-on budget is the cost doctrine made measurable: SKILL.md, the managed
30
+ teamwork block of AGENTS.md, the role charter and the role's memory files are in
31
+ context on every run, so they are the expensive option and the only one capped.
32
+ ``AlwaysOnBudgetKb`` in the team ``Settings.json`` sets the cap; missing,
33
+ unreadable or non-numeric settings fall back to
34
+ :data:`teamwork.config.DEFAULT_ALWAYS_ON_BUDGET_KB`.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import json
41
+ import re
42
+ from pathlib import Path
43
+ from typing import NamedTuple
44
+
45
+ from .config import (
46
+ DEFAULT_ALWAYS_ON_BUDGET_KB,
47
+ DIR_AGENTS,
48
+ DIR_ROLES,
49
+ F_GLOBAL,
50
+ F_LONG,
51
+ F_ROLE,
52
+ F_SETTINGS,
53
+ F_SHORT,
54
+ )
55
+ from .gitutil import HooksDirUnresolved, run_git
56
+ from .gitutil import hooks_dir as resolved_hooks_dir
57
+ from .io import die
58
+ from .layout import (
59
+ global_dir,
60
+ known_instances,
61
+ list_roles,
62
+ project_of,
63
+ project_path,
64
+ project_root,
65
+ role_dir,
66
+ skills_path,
67
+ )
68
+ from .names import resolve_role
69
+ from .records import parse_skills
70
+
71
+ #: The three activation classes (12.3), cheapest to reason about first: always-on
72
+ #: text is in context on every call, on-demand text is opened when a skill or a
73
+ #: reference is needed, and in-loop code runs inside the tool loop.
74
+ ALWAYS_ON = "always-on"
75
+ ON_DEMAND = "on-demand"
76
+ IN_LOOP = "in-loop"
77
+ CLASSES = (ALWAYS_ON, ON_DEMAND, IN_LOOP)
78
+
79
+ NO_VALUE = "-"
80
+
81
+ #: C5 is an inference context, not an artifact: it has no file, so its class, byte
82
+ #: and role cells are ``-`` (12.2: ``-`` is the value for the component with no
83
+ #: file) and its path cell says what it is instead of naming a path.
84
+ LANE_PACKET = "n/a - lane packet, no file"
85
+
86
+ #: The closed set of values the class column may print: the three activation
87
+ #: classes plus `-`, the one component that is an inference context, not a file.
88
+ CLASS_CELLS = CLASSES + (NO_VALUE,)
89
+
90
+ #: The path column, verbatim from the frozen registry table of Architecture 12.2.
91
+ #: Each string is the spec the row's bytes are measured through, so the number can
92
+ #: be recomputed from the cell alone: the managed block (not the file that holds
93
+ #: it), the union of both globs (not one member of it), both hook stages
94
+ #: through the resolver (not one file under a path that does not exist in a
95
+ #: worktree). A prettier spelling would describe the component and hide the
96
+ #: measurement, which is the defect this column exists to prevent.
97
+ SPEC_SYSTEM_PROMPT = "SKILL.md"
98
+ SPEC_GUARDRAILS = "managed block in AGENTS.md"
99
+ SPEC_MEMORY = "Roles/<Role>/*.md + Global/GlobalMemory.md"
100
+ SPEC_TOOL_GUIDANCE = "references/*.md"
101
+ SPEC_SKILLS = ".agents/skills/*/SKILL.md"
102
+ SPEC_SCRIPTS = "scripts/*.py + src/teamwork/*.py"
103
+ SPEC_HOOKS = "hook_dir(root)/{pre-commit,pre-push}"
104
+
105
+ #: The two stages the `hooks` command group writes; C8 measures both.
106
+ HOOK_STAGES = ("pre-commit", "pre-push")
107
+
108
+ RULE_IDENTITY = "IDENTITY"
109
+ RULE_TRIGGER = "TRIGGER"
110
+ RULE_SUBCOMMAND = "SUBCOMMAND"
111
+ RULE_HELPER = "HELPER"
112
+ RULE_EVIDENCE = "EVIDENCE"
113
+ RULES = (RULE_IDENTITY, RULE_TRIGGER, RULE_SUBCOMMAND, RULE_HELPER, RULE_EVIDENCE)
114
+
115
+ SKILLS_DIRNAME = Path(".agents") / "skills"
116
+
117
+ #: The four verdicts an evidence row may carry. ``NO_ENTRY`` - no row at all - is a
118
+ #: valid answer for a draft skill and only becomes a failure once it is shared.
119
+ VERDICT_RE = re.compile(
120
+ r"verdict:\s*(?:helped|hurt|no\s+observed\s+effect|confounded)\b",
121
+ re.IGNORECASE,
122
+ )
123
+
124
+ _COMMAND_RE = re.compile(r"teamwork\.py\s+([a-z][a-z0-9_-]*)")
125
+
126
+ #: A token that looks like a repository-relative path: at least two segments and a
127
+ #: file extension. The lookbehind keeps URL host names (``https://host/a/b.md``)
128
+ #: and shell-variable roots (``$SKILL/scripts/x.py``) out: their existence cannot
129
+ #: be decided from the repository, so the match may not start mid-token.
130
+ _PATH_RE = re.compile(
131
+ r"(?<![\w/.$-])([A-Za-z0-9_][A-Za-z0-9_./-]*/[A-Za-z0-9_./-]*\.[A-Za-z][A-Za-z0-9]{0,7})"
132
+ )
133
+
134
+ #: Characters that make a path token a template, a glob or a shell variable rather
135
+ #: than a file the skill promises to ship.
136
+ _PLACEHOLDER_CHARS = ("<", ">", "{", "}", "$", "*", "?", "~")
137
+
138
+
139
+ class Surface(NamedTuple):
140
+ """One registry row. ``size`` is ``None`` when the surface is not a file."""
141
+
142
+ component: str
143
+ klass: str
144
+ path: str
145
+ role: str
146
+ size: int | None
147
+
148
+
149
+ class Finding(NamedTuple):
150
+ """One lint failure, rendered as ``- PROBLEM <rule> <path>: <detail>``."""
151
+
152
+ rule: str
153
+ path: str
154
+ detail: str
155
+
156
+
157
+ #: What one rule said about one file, for ``lint --verbose``. ``ok``/``fail`` are
158
+ #: verdicts; ``corpus`` marks a file read as evidence for a rule that reasons over
159
+ #: several files at once (TRIGGER reads every always-on surface, EVIDENCE reads the
160
+ #: team registry); ``n/a`` marks a rule that does not apply to that file; ``skipped``
161
+ #: marks a rule that could not run at all.
162
+ OK = "ok"
163
+ FAIL = "fail"
164
+ CORPUS = "corpus"
165
+ NOT_APPLICABLE = "n/a"
166
+ SKIPPED = "skipped"
167
+
168
+
169
+ class CheckedFile(NamedTuple):
170
+ """One file ``lint`` opened, with the rules evaluated against it and the verdicts."""
171
+
172
+ path: str
173
+ outcomes: tuple
174
+
175
+
176
+ class LintStats(NamedTuple):
177
+ """What the last lint actually looked at, for ``--verbose`` and for tests."""
178
+
179
+ repo: str
180
+ skills: int
181
+ always_on: list[str]
182
+ commands: int
183
+ shared_checked: int
184
+ registry: str
185
+ checked: list[CheckedFile]
186
+
187
+
188
+ class BudgetRow(NamedTuple):
189
+ """One instance's always-on cost against the cap."""
190
+
191
+ instance: str
192
+ always_on: int
193
+ budget: int
194
+
195
+ @property
196
+ def over(self) -> bool:
197
+ return self.always_on > self.budget
198
+
199
+
200
+ # --------------------------------------------------------------------------- #
201
+ # Measuring the tree
202
+ # --------------------------------------------------------------------------- #
203
+
204
+
205
+ def repo_root(args) -> Path:
206
+ """The work tree the command acts on: the git toplevel, else the project dir."""
207
+ here = project_path(args)
208
+ result = run_git(here, "rev-parse", "--show-toplevel")
209
+ if result.returncode == 0 and result.stdout.strip():
210
+ return Path(result.stdout.strip()).resolve()
211
+ return here
212
+
213
+
214
+ def file_size(path: Path) -> int:
215
+ try:
216
+ return path.stat().st_size
217
+ except OSError:
218
+ return 0
219
+
220
+
221
+ def glob_size(base: Path, pattern: str) -> int:
222
+ """Bytes of every file ``base/pattern`` matches right now; no match is 0."""
223
+ try:
224
+ matches = sorted(base.glob(pattern))
225
+ except OSError: # pragma: no cover - only an unreadable directory gets here
226
+ return 0
227
+ return sum(file_size(path) for path in matches if path.is_file())
228
+
229
+
230
+ def managed_block_size(path: Path) -> int:
231
+ """Bytes of the CEO-owned teamwork block inside AGENTS.md, markers included.
232
+
233
+ The markers are the engine's own pair (`agents-md` writes them): a second copy
234
+ of a marker literal is a second source of truth.
235
+ """
236
+ from . import engine # local: the command layer registers this module
237
+
238
+ try:
239
+ text = path.read_text(encoding="utf-8")
240
+ except OSError:
241
+ return 0
242
+ start = text.find(engine.BEGIN)
243
+ end = text.find(engine.END)
244
+ if start < 0 or end < start:
245
+ return 0
246
+ return len(text[start : end + len(engine.END)].encode("utf-8"))
247
+
248
+
249
+ def hooks_dir(repo: Path) -> Path | None:
250
+ """``<git-dir>/hooks`` resolved by git, or ``None`` when git cannot answer.
251
+
252
+ This is the tolerant face of the one contract in :func:`teamwork.gitutil.hooks_dir`
253
+ - same ``rev-parse``, same resolution through the common git directory, so a
254
+ worktree row and the installer row name the same directory. A report must not
255
+ abort because a directory is missing, so a failure here is a reported absence;
256
+ the installer, which writes into the directory, lets it die instead.
257
+ """
258
+ try:
259
+ return resolved_hooks_dir(repo)
260
+ except HooksDirUnresolved:
261
+ return None
262
+
263
+
264
+ def display(path: Path, base: Path) -> str:
265
+ """A repo-relative path when it is inside the base, else the absolute one."""
266
+ try:
267
+ return path.resolve().relative_to(base.resolve()).as_posix()
268
+ except ValueError:
269
+ return path.resolve().as_posix()
270
+
271
+
272
+ def collect(repo: Path, root: Path) -> list[Surface]:
273
+ """The eight components, in C1..C8 order, measured on disk now.
274
+
275
+ One row per Harness^2 component, never one per file: an inventory that splits a
276
+ component across rows cannot be checked against the taxonomy it claims to
277
+ describe. Each row's ``path`` is the frozen spec (Architecture 12.2) and
278
+ ``size`` is what that spec selects at run time: C2 is the managed block inside
279
+ AGENTS.md, C3 is every role's markdown plus the global memory, C7 is the
280
+ scripts plus the engine package, C8 is both hook stages found through the git
281
+ hook directory. Measuring something other than what the cell names would make
282
+ the printed number unreproducible from the row, which is what the registry is
283
+ for.
284
+ """
285
+ hooks = hooks_dir(repo)
286
+ return [
287
+ Surface("C1", ALWAYS_ON, SPEC_SYSTEM_PROMPT, "ceo", file_size(repo / "SKILL.md")),
288
+ Surface(
289
+ "C2",
290
+ ALWAYS_ON,
291
+ SPEC_GUARDRAILS,
292
+ "ceo",
293
+ managed_block_size(repo / "AGENTS.md"),
294
+ ),
295
+ Surface("C3", ALWAYS_ON, SPEC_MEMORY, NO_VALUE, memory_bytes(root)),
296
+ Surface("C4", ON_DEMAND, SPEC_TOOL_GUIDANCE, "ceo", glob_size(repo, "references/*.md")),
297
+ Surface("C5", NO_VALUE, LANE_PACKET, NO_VALUE, None),
298
+ Surface(
299
+ "C6",
300
+ ON_DEMAND,
301
+ SPEC_SKILLS,
302
+ "ceo",
303
+ glob_size(repo, ".agents/skills/*/SKILL.md"),
304
+ ),
305
+ Surface(
306
+ "C7",
307
+ IN_LOOP,
308
+ SPEC_SCRIPTS,
309
+ "developer",
310
+ glob_size(repo, "scripts/*.py") + glob_size(repo, "src/teamwork/*.py"),
311
+ ),
312
+ Surface(
313
+ "C8",
314
+ IN_LOOP,
315
+ SPEC_HOOKS,
316
+ NO_VALUE,
317
+ sum(file_size(hooks / stage) for stage in HOOK_STAGES) if hooks is not None else 0,
318
+ ),
319
+ ]
320
+
321
+
322
+ def memory_bytes(root: Path) -> int:
323
+ """C3: every role's markdown plus the shared global memory of the team."""
324
+ total = file_size(global_dir(root) / F_GLOBAL)
325
+ for role in list_roles(root):
326
+ total += glob_size(role_dir(root, role), "*.md")
327
+ return total
328
+
329
+
330
+ #: A row's third cell is a spec, and the frozen specs contain single spaces
331
+ #: (``managed block in AGENTS.md``), so the cells are separated by two or more
332
+ #: spaces and split on that: ``split()`` would tear a spec in half.
333
+ _CELL_SEPARATOR_RE = re.compile(r"\s{2,}")
334
+
335
+
336
+ def render_surfaces(rows: list[Surface]) -> str:
337
+ """Exactly eight rows, four columns, no header: the frozen registry format."""
338
+ path_width = max([len(row.path) for row in rows] + [len("path")])
339
+ class_width = max(len(row.klass) for row in rows)
340
+ lines = []
341
+ for row in rows:
342
+ size = NO_VALUE if row.size is None else str(row.size)
343
+ lines.append(f"{row.klass:<{class_width}} {size:>9} {row.path:<{path_width}} {row.role}")
344
+ return "\n".join(lines)
345
+
346
+
347
+ def parse_surface_row(line: str) -> tuple:
348
+ """``(class, bytes, path, role)`` from one printed row - the inverse of render.
349
+
350
+ The bytes cell stays a string (``-`` for C5): a caller that wants a number has
351
+ to decide what ``-`` means, and pretending it is ``0`` would turn "not
352
+ measured" into a measurement.
353
+ """
354
+ cells = _CELL_SEPARATOR_RE.split(line.strip())
355
+ if len(cells) != 4:
356
+ raise ValueError(f"not a four-column registry row: {line!r} -> {cells!r}")
357
+ klass, size, path, role = cells
358
+ return klass, size, path, role
359
+
360
+
361
+ def class_totals(rows: list[Surface]) -> dict:
362
+ """Bytes per activation class, summed over the eight rows.
363
+
364
+ The registry prints its eight rows and nothing else, so the cost roll-up a
365
+ reader wants ("what does always-on cost me") is computed here rather than
366
+ appended to the table the frozen format fixes.
367
+ """
368
+ return {klass: sum(row.size or 0 for row in rows if row.klass == klass) for klass in CLASSES}
369
+
370
+
371
+ # --------------------------------------------------------------------------- #
372
+ # lint: the whole-proposal invalidators
373
+ # --------------------------------------------------------------------------- #
374
+
375
+
376
+ def parse_frontmatter(text: str) -> dict:
377
+ """The scalar fields of a leading ``---`` block; a repeated key wins last."""
378
+ lines = text.splitlines()
379
+ if not lines or lines[0].strip() != "---":
380
+ return {}
381
+ out: dict = {}
382
+ for raw in lines[1:]:
383
+ if raw.strip() == "---":
384
+ break
385
+ match = re.match(r"^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$", raw)
386
+ if match is None:
387
+ continue
388
+ value = match.group(2).strip()
389
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
390
+ value = value[1:-1].strip()
391
+ out[match.group(1).lower()] = value
392
+ return out
393
+
394
+
395
+ def skill_files(repo: Path) -> list[Path]:
396
+ """``.agents/skills/<dir>/SKILL.md`` - one skill per directory, none nested."""
397
+ try:
398
+ found = sorted((repo / SKILLS_DIRNAME).glob("*/SKILL.md"))
399
+ except OSError: # pragma: no cover
400
+ return []
401
+ return [path for path in found if path.is_file()]
402
+
403
+
404
+ def always_on_surfaces(repo: Path) -> list[Path]:
405
+ """The files a skill name must appear in for the skill to be reachable."""
406
+ names = [repo / "AGENTS.md", repo / "SKILL.md"]
407
+ roles = repo / "roles"
408
+ if roles.is_dir():
409
+ names.extend(sorted(roles.glob("*.md")))
410
+ return [path for path in names if path.is_file()]
411
+
412
+
413
+ def cli_subcommands() -> set:
414
+ """Every ``teamwork.py <word>`` the parser actually accepts.
415
+
416
+ Read off the parser, never copied: the scripts *are* the engine, so a
417
+ hardcoded list would drift the first time a command is renamed.
418
+ """
419
+ from . import engine # local: the command layer is what registers this module
420
+
421
+ parser = engine.build_parser()
422
+ names: set = set()
423
+ for action in parser._actions:
424
+ if isinstance(action, argparse._SubParsersAction):
425
+ names.update(action.choices)
426
+ return names
427
+
428
+
429
+ def helper_paths(text: str) -> list[str]:
430
+ """Repository-relative paths a skill file claims to ship, placeholders dropped."""
431
+ out = set()
432
+ for raw in _PATH_RE.findall(text):
433
+ token = raw.strip("\"'`()[].,;:")
434
+ if not token or any(char in token for char in _PLACEHOLDER_CHARS):
435
+ continue
436
+ if token.startswith(("/", "http", "www")) or "://" in token:
437
+ continue
438
+ if ".." in token.split("/"):
439
+ continue
440
+ out.add(token)
441
+ return sorted(out)
442
+
443
+
444
+ def lint(repo: Path, root: Path) -> tuple[list[Finding], LintStats]:
445
+ """Run every rule over the tree; return the findings and what was checked."""
446
+ findings: list[Finding] = []
447
+ surfaces = always_on_surfaces(repo)
448
+ corpus = "\n".join(path.read_text(encoding="utf-8") for path in surfaces)
449
+ commands = cli_subcommands()
450
+ files = skill_files(repo)
451
+ registry = skills_path(root)
452
+ has_registry = registry.is_file()
453
+
454
+ # Files read for a rule that reasons across files rather than per file: the
455
+ # always-on corpus TRIGGER searches, and the team registry EVIDENCE reads.
456
+ checked: list[CheckedFile] = [
457
+ CheckedFile(display(path, repo), ((RULE_TRIGGER, CORPUS),)) for path in surfaces
458
+ ]
459
+
460
+ evidence: list[Finding] = []
461
+ evaluated: list[str] = []
462
+ shared_checked = 0
463
+ if has_registry:
464
+ evidence, evaluated, shared_checked = evidence_findings(repo, registry)
465
+
466
+ for path in files:
467
+ rel = display(path, repo)
468
+ text = path.read_text(encoding="utf-8")
469
+ fields = parse_frontmatter(text)
470
+ name = (fields.get("name") or "").strip()
471
+ directory = path.parent.name
472
+ mine: list[Finding] = []
473
+
474
+ if not name:
475
+ mine.append(Finding(RULE_IDENTITY, rel, "frontmatter has no `name:` line"))
476
+ elif name != directory:
477
+ mine.append(
478
+ Finding(
479
+ RULE_IDENTITY,
480
+ rel,
481
+ f"frontmatter name '{name}' does not match the directory '{directory}'",
482
+ )
483
+ )
484
+ if not (fields.get("description") or "").strip():
485
+ mine.append(
486
+ Finding(RULE_IDENTITY, rel, "frontmatter `description:` is missing or empty")
487
+ )
488
+
489
+ if directory and directory not in corpus:
490
+ mine.append(
491
+ Finding(
492
+ RULE_TRIGGER,
493
+ rel,
494
+ f"'{directory}' appears in no always-on surface "
495
+ "(AGENTS.md, SKILL.md, roles/*.md), so the skill is silently dead",
496
+ )
497
+ )
498
+
499
+ for word in sorted(set(_COMMAND_RE.findall(text))):
500
+ if word not in commands:
501
+ mine.append(
502
+ Finding(RULE_SUBCOMMAND, rel, f"`teamwork.py {word}` is not a CLI subcommand")
503
+ )
504
+
505
+ for token in helper_paths(text):
506
+ if not (repo / token).exists():
507
+ mine.append(
508
+ Finding(RULE_HELPER, rel, f"references '{token}', which does not exist")
509
+ )
510
+
511
+ findings.extend(mine)
512
+ checked.append(CheckedFile(rel, _outcomes(mine, rel, evidence, evaluated, has_registry)))
513
+
514
+ findings.extend(evidence)
515
+ if has_registry:
516
+ checked.append(CheckedFile(display(registry, project_of(root)), ((RULE_EVIDENCE, CORPUS),)))
517
+
518
+ findings = ordered(findings)
519
+ stats = LintStats(
520
+ repo=str(repo),
521
+ skills=len(files),
522
+ always_on=[display(path, repo) for path in surfaces],
523
+ commands=len(commands),
524
+ shared_checked=shared_checked,
525
+ registry=display(registry, project_of(root)) if has_registry else "",
526
+ checked=checked,
527
+ )
528
+ return findings, stats
529
+
530
+
531
+ def _outcomes(
532
+ mine: list[Finding], rel: str, evidence: list[Finding], evaluated: list[str], has_registry: bool
533
+ ) -> tuple:
534
+ """The verdict of each rule on one skill file, in frozen rule order."""
535
+ failed = {finding.rule for finding in mine}
536
+ out = [(rule, FAIL if rule in failed else OK) for rule in RULES[:4]]
537
+ if not has_registry:
538
+ out.append((RULE_EVIDENCE, SKIPPED))
539
+ elif rel not in evaluated:
540
+ out.append((RULE_EVIDENCE, NOT_APPLICABLE))
541
+ elif any(finding.path == rel for finding in evidence):
542
+ out.append((RULE_EVIDENCE, FAIL))
543
+ else:
544
+ out.append((RULE_EVIDENCE, OK))
545
+ return tuple(out)
546
+
547
+
548
+ def render_checked(checked: list[CheckedFile]) -> list[str]:
549
+ """One ``# checked: <file> -> <RULE verdict, ...>`` line per file, rule order."""
550
+ return [
551
+ f"# checked: {item.path} -> "
552
+ + ", ".join(f"{rule} {outcome}" for rule, outcome in item.outcomes)
553
+ for item in checked
554
+ ]
555
+
556
+
557
+ def ordered(findings: list[Finding]) -> list[Finding]:
558
+ """Rule order first (IDENTITY..EVIDENCE), path order inside a rule."""
559
+ rank = {rule: index for index, rule in enumerate(RULES)}
560
+ return sorted(findings, key=lambda finding: (rank.get(finding.rule, len(rank)), finding.path))
561
+
562
+
563
+ def evidence_findings(repo: Path, registry: Path) -> tuple[list[Finding], list[str], int]:
564
+ """EVIDENCE: a shared skill must carry an evidence row; a draft skill need not.
565
+
566
+ Returns the findings, the paths the rule evaluated (shared entries, so
567
+ ``--verbose`` can say which file was checked for what), and how many of them
568
+ were readable skill files.
569
+ """
570
+ findings: list[Finding] = []
571
+ evaluated: list[str] = []
572
+ checked = 0
573
+ for entry in parse_skills(registry):
574
+ if (entry.get("status") or "").strip().lower() != "shared":
575
+ continue
576
+ rel = (entry.get("path") or "").strip()
577
+ evaluated.append(rel or entry.get("name") or "?")
578
+ skill = repo / rel if rel else None
579
+ if skill is None or not skill.is_file():
580
+ findings.append(
581
+ Finding(
582
+ RULE_HELPER,
583
+ rel or entry.get("name") or "?",
584
+ "registered in Skills.md as shared, but the file does not exist",
585
+ )
586
+ )
587
+ continue
588
+ checked += 1
589
+ if not VERDICT_RE.search(skill.read_text(encoding="utf-8")):
590
+ findings.append(
591
+ Finding(
592
+ RULE_EVIDENCE,
593
+ rel,
594
+ "shared skill has no evidence row "
595
+ "(`verdict: helped|hurt|no observed effect|confounded`)",
596
+ )
597
+ )
598
+ return findings, evaluated, checked
599
+
600
+
601
+ # --------------------------------------------------------------------------- #
602
+ # budget: the always-on cap, per instance
603
+ # --------------------------------------------------------------------------- #
604
+
605
+
606
+ def settings_budget_kb(root: Path) -> int | None:
607
+ """``AlwaysOnBudgetKb`` from the team settings, or ``None`` when unusable."""
608
+ try:
609
+ data = json.loads((root / F_SETTINGS).read_text(encoding="utf-8"))
610
+ except (OSError, ValueError):
611
+ return None
612
+ if not isinstance(data, dict):
613
+ return None
614
+ value = data.get("AlwaysOnBudgetKb")
615
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
616
+ return None
617
+ return value
618
+
619
+
620
+ def charter_path(repo: Path, root: Path, role: str) -> Path:
621
+ """The charter an instance actually reads: the team copy, else the bundle copy."""
622
+ team_copy = role_dir(root, role) / F_ROLE
623
+ return team_copy if team_copy.is_file() else repo / "roles" / f"{role}.md"
624
+
625
+
626
+ def instance_always_on(repo: Path, root: Path, role: str) -> int:
627
+ """What is in context on every run for one instance, in bytes."""
628
+ return (
629
+ managed_block_size(repo / "AGENTS.md")
630
+ + file_size(repo / "SKILL.md")
631
+ + file_size(charter_path(repo, root, role))
632
+ + file_size(role_dir(root, role) / F_SHORT)
633
+ + file_size(role_dir(root, role) / F_LONG)
634
+ )
635
+
636
+
637
+ def budget_rows(repo: Path, root: Path, limit_kb: int | None) -> tuple[list[BudgetRow], int, str]:
638
+ """One row per agent instance, the cap in bytes, and where the cap came from."""
639
+ configured = settings_budget_kb(root)
640
+ if limit_kb is not None:
641
+ kb, source = limit_kb, "--limit"
642
+ elif configured is not None:
643
+ kb, source = configured, "Settings.json AlwaysOnBudgetKb"
644
+ else:
645
+ kb, source = DEFAULT_ALWAYS_ON_BUDGET_KB, "default"
646
+ cap = max(kb, 0) * 1024
647
+ rows = [
648
+ BudgetRow(instance, instance_always_on(repo, root, resolve_role(instance)), cap)
649
+ for instance in known_instances(root)
650
+ ]
651
+ return rows, cap, source
652
+
653
+
654
+ def render_budget(rows: list[BudgetRow]) -> str:
655
+ """``<instance> <always_on> <budget> <ok|OVER>`` per row, then the total line."""
656
+ lines = [
657
+ f"{row.instance} {row.always_on} {row.budget} {'OVER' if row.over else 'ok'}"
658
+ for row in rows
659
+ ]
660
+ state = "OVER" if any(row.over for row in rows) else "ok"
661
+ lines.append(
662
+ f"total {sum(row.always_on for row in rows)} {sum(row.budget for row in rows)} {state}"
663
+ )
664
+ return "\n".join(lines)
665
+
666
+
667
+ # --------------------------------------------------------------------------- #
668
+ # Commands
669
+ # --------------------------------------------------------------------------- #
670
+
671
+
672
+ def cmd_list(args) -> int:
673
+ repo = repo_root(args)
674
+ root = project_root(args)
675
+ print(render_surfaces(collect(repo, root)))
676
+ return 0
677
+
678
+
679
+ def cmd_lint(args) -> int:
680
+ repo = repo_root(args)
681
+ root = project_root(args)
682
+ findings, stats = lint(repo, root)
683
+ if args.verbose:
684
+ print(f"# repo: {stats.repo}")
685
+ print(f"# skill files: {stats.skills}")
686
+ print(f"# cli subcommands: {stats.commands}")
687
+ print(f"# always-on surfaces: {', '.join(stats.always_on) or '(none)'}")
688
+ if stats.registry:
689
+ print(f"# EVIDENCE: {stats.shared_checked} shared skill(s) checked")
690
+ else:
691
+ print("# EVIDENCE: skipped (no team workspace / no Skills.md)")
692
+ # 12.4: --verbose also lists every file checked, so a clean run is
693
+ # auditable file by file instead of being a count the reader must trust.
694
+ for line in render_checked(stats.checked):
695
+ print(line)
696
+ for finding in findings:
697
+ print(f"- PROBLEM {finding.rule} {finding.path}: {finding.detail}")
698
+ print(f"# problems found: {len(findings)}")
699
+ return 1 if findings else 0
700
+
701
+
702
+ def cmd_budget(args) -> int:
703
+ repo = repo_root(args)
704
+ root = project_root(args)
705
+ if not (root / DIR_ROLES).is_dir() and not (root / DIR_AGENTS).is_dir():
706
+ die(
707
+ f"no team workspace at {root}: `harness budget` measures the always-on "
708
+ "context every instance loads, so it needs `teamwork.py init` first."
709
+ )
710
+ rows, cap, source = budget_rows(repo, root, args.limit)
711
+ print(f"# always-on budget for {root}")
712
+ print(f"# cap: {cap} bytes per instance ({source})")
713
+ if not rows:
714
+ print("# no agent instances in this team yet")
715
+ print(render_budget(rows))
716
+ return 1 if any(row.over for row in rows) else 0
717
+
718
+
719
+ def add_parsers(sub, common) -> None:
720
+ """Register the ``harness`` command group on the shared argparse tree."""
721
+ surface = sub.add_parser(
722
+ "harness",
723
+ parents=[common],
724
+ help="name, measure and lint the harness surface (Harness^2 C1..C8)",
725
+ )
726
+ hsub = surface.add_subparsers(dest="harness_cmd", required=True)
727
+
728
+ parser = hsub.add_parser(
729
+ "list", parents=[common], help="every surface with its class, bytes and role"
730
+ )
731
+ parser.set_defaults(func=cmd_list)
732
+
733
+ parser = hsub.add_parser(
734
+ "lint",
735
+ parents=[common],
736
+ help="whole-proposal checks: identity, trigger, subcommand, helper, evidence",
737
+ )
738
+ parser.add_argument(
739
+ "--verbose", action="store_true", help="print what was checked and what was skipped"
740
+ )
741
+ parser.set_defaults(func=cmd_lint)
742
+
743
+ parser = hsub.add_parser(
744
+ "budget", parents=[common], help="always-on bytes per instance against AlwaysOnBudgetKb"
745
+ )
746
+ parser.add_argument(
747
+ "--limit",
748
+ type=int,
749
+ default=None,
750
+ metavar="KB",
751
+ help="override AlwaysOnBudgetKb for this run only",
752
+ )
753
+ parser.set_defaults(func=cmd_budget)